From 1e8a8e7482d4412f13a98121d208399fb48da3e1 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Mon, 19 Aug 2024 11:37:36 -0700 Subject: [PATCH 1/5] Docs: `NetworkItem.player` (#3811) * Docs: `NetworkItem.player` In many contexts, it's difficult to tell whether this is the sending player or the receiving player. * correct player info * Update NetUtils.py Co-authored-by: Aaron Wagener --------- Co-authored-by: Aaron Wagener --- NetUtils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/NetUtils.py b/NetUtils.py index f79773728cd6..c451fa3f8460 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -79,6 +79,7 @@ class NetworkItem(typing.NamedTuple): item: int location: int player: int + """ Sending player, except in LocationInfo (from LocationScouts), where it is the receiving player. """ flags: int = 0 From c010c8c938921da9d58c0522d40f09f4c72ee223 Mon Sep 17 00:00:00 2001 From: KonoTyran Date: Mon, 19 Aug 2024 15:58:30 -0700 Subject: [PATCH 2/5] Minecraft: Update to new options system. (#3765) * Move to new options system. switch to using self.random reformat rules file. * further reformats * fix tests to use new options system. * fix slot data to not use self.multiworld * I hate python * new starting_items docstring to prepare for 1.20.5+ item components. fix invalid json being output to starting_items * more typing fixes. * stupid quotes around type declarations * removed unused variable in ItemPool.py change null check in Structures.py * update rules "self" variable to a "world: MinecraftWorld" variable * get key, and not value for required bosses. --- worlds/minecraft/ItemPool.py | 37 +- worlds/minecraft/Options.py | 59 ++- worlds/minecraft/Rules.py | 663 +++++++++++++++++---------- worlds/minecraft/Structures.py | 28 +- worlds/minecraft/__init__.py | 38 +- worlds/minecraft/test/TestOptions.py | 12 +- worlds/minecraft/test/__init__.py | 4 +- 7 files changed, 525 insertions(+), 316 deletions(-) diff --git a/worlds/minecraft/ItemPool.py b/worlds/minecraft/ItemPool.py index 78eeffca80f5..19bb70ed6402 100644 --- a/worlds/minecraft/ItemPool.py +++ b/worlds/minecraft/ItemPool.py @@ -1,10 +1,14 @@ from math import ceil from typing import List -from BaseClasses import MultiWorld, Item -from worlds.AutoWorld import World +from BaseClasses import Item from . import Constants +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import MinecraftWorld + def get_junk_item_names(rand, k: int) -> str: junk_weights = Constants.item_info["junk_weights"] @@ -14,39 +18,38 @@ def get_junk_item_names(rand, k: int) -> str: k=k) return junk -def build_item_pool(mc_world: World) -> List[Item]: - multiworld = mc_world.multiworld - player = mc_world.player +def build_item_pool(world: "MinecraftWorld") -> List[Item]: + multiworld = world.multiworld + player = world.player itempool = [] total_location_count = len(multiworld.get_unfilled_locations(player)) required_pool = Constants.item_info["required_pool"] - junk_weights = Constants.item_info["junk_weights"] # Add required progression items for item_name, num in required_pool.items(): - itempool += [mc_world.create_item(item_name) for _ in range(num)] + itempool += [world.create_item(item_name) for _ in range(num)] # Add structure compasses - if multiworld.structure_compasses[player]: - compasses = [name for name in mc_world.item_name_to_id if "Structure Compass" in name] + if world.options.structure_compasses: + compasses = [name for name in world.item_name_to_id if "Structure Compass" in name] for item_name in compasses: - itempool.append(mc_world.create_item(item_name)) + itempool.append(world.create_item(item_name)) # Dragon egg shards - if multiworld.egg_shards_required[player] > 0: - num = multiworld.egg_shards_available[player] - itempool += [mc_world.create_item("Dragon Egg Shard") for _ in range(num)] + if world.options.egg_shards_required > 0: + num = world.options.egg_shards_available + itempool += [world.create_item("Dragon Egg Shard") for _ in range(num)] # Bee traps - bee_trap_percentage = multiworld.bee_traps[player] * 0.01 + bee_trap_percentage = world.options.bee_traps * 0.01 if bee_trap_percentage > 0: bee_trap_qty = ceil(bee_trap_percentage * (total_location_count - len(itempool))) - itempool += [mc_world.create_item("Bee Trap") for _ in range(bee_trap_qty)] + itempool += [world.create_item("Bee Trap") for _ in range(bee_trap_qty)] # Fill remaining itempool with randomly generated junk - junk = get_junk_item_names(multiworld.random, total_location_count - len(itempool)) - itempool += [mc_world.create_item(name) for name in junk] + junk = get_junk_item_names(world.random, total_location_count - len(itempool)) + itempool += [world.create_item(name) for name in junk] return itempool diff --git a/worlds/minecraft/Options.py b/worlds/minecraft/Options.py index 9407097b4638..7d1377233e4c 100644 --- a/worlds/minecraft/Options.py +++ b/worlds/minecraft/Options.py @@ -1,6 +1,7 @@ -import typing -from Options import Choice, Option, Toggle, DefaultOnToggle, Range, OptionList, DeathLink, PlandoConnections +from Options import Choice, Toggle, DefaultOnToggle, Range, OptionList, DeathLink, PlandoConnections, \ + PerGameCommonOptions from .Constants import region_info +from dataclasses import dataclass class AdvancementGoal(Range): @@ -55,7 +56,7 @@ class StructureCompasses(DefaultOnToggle): display_name = "Structure Compasses" -class BeeTraps(Range): +class BeeTraps(Range): """Replaces a percentage of junk items with bee traps, which spawn multiple angered bees around every player when received.""" display_name = "Bee Trap Percentage" @@ -94,7 +95,20 @@ class SendDefeatedMobs(Toggle): class StartingItems(OptionList): - """Start with these items. Each entry should be of this format: {item: "item_name", amount: #, nbt: "nbt_string"}""" + """Start with these items. Each entry should be of this format: {item: "item_name", amount: #} + `item` can include components, and should be in an identical format to a `/give` command with + `"` escaped for json reasons. + + `amount` is optional and will default to 1 if omitted. + + example: + ``` + starting_items: [ + { "item": "minecraft:stick[minecraft:custom_name=\"{'text':'pointy stick'}\"]" }, + { "item": "minecraft:arrow[minecraft:rarity=epic]", amount: 64 } + ] + ``` + """ display_name = "Starting Items" @@ -109,22 +123,21 @@ def can_connect(cls, entrance, exit): return True -minecraft_options: typing.Dict[str, type(Option)] = { - "plando_connections": MCPlandoConnections, - "advancement_goal": AdvancementGoal, - "egg_shards_required": EggShardsRequired, - "egg_shards_available": EggShardsAvailable, - "required_bosses": BossGoal, - - "shuffle_structures": ShuffleStructures, - "structure_compasses": StructureCompasses, - - "combat_difficulty": CombatDifficulty, - "include_hard_advancements": HardAdvancements, - "include_unreasonable_advancements": UnreasonableAdvancements, - "include_postgame_advancements": PostgameAdvancements, - "bee_traps": BeeTraps, - "send_defeated_mobs": SendDefeatedMobs, - "death_link": DeathLink, - "starting_items": StartingItems, -} +@dataclass +class MinecraftOptions(PerGameCommonOptions): + plando_connections: MCPlandoConnections + advancement_goal: AdvancementGoal + egg_shards_required: EggShardsRequired + egg_shards_available: EggShardsAvailable + required_bosses: BossGoal + shuffle_structures: ShuffleStructures + structure_compasses: StructureCompasses + + combat_difficulty: CombatDifficulty + include_hard_advancements: HardAdvancements + include_unreasonable_advancements: UnreasonableAdvancements + include_postgame_advancements: PostgameAdvancements + bee_traps: BeeTraps + send_defeated_mobs: SendDefeatedMobs + death_link: DeathLink + starting_items: StartingItems diff --git a/worlds/minecraft/Rules.py b/worlds/minecraft/Rules.py index dae4241b992c..9a7be09a4a84 100644 --- a/worlds/minecraft/Rules.py +++ b/worlds/minecraft/Rules.py @@ -1,276 +1,471 @@ -import typing -from collections.abc import Callable - from BaseClasses import CollectionState from worlds.generic.Rules import exclusion_rules -from worlds.AutoWorld import World from . import Constants +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import MinecraftWorld + # Helper functions # moved from logicmixin -def has_iron_ingots(state: CollectionState, player: int) -> bool: +def has_iron_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) -def has_copper_ingots(state: CollectionState, player: int) -> bool: + +def has_copper_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) -def has_gold_ingots(state: CollectionState, player: int) -> bool: - return state.has('Progressive Resource Crafting', player) and (state.has('Progressive Tools', player, 2) or state.can_reach('The Nether', 'Region', player)) -def has_diamond_pickaxe(state: CollectionState, player: int) -> bool: - return state.has('Progressive Tools', player, 3) and has_iron_ingots(state, player) +def has_gold_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return (state.has('Progressive Resource Crafting', player) + and ( + state.has('Progressive Tools', player, 2) + or state.can_reach_region('The Nether', player) + ) + ) + -def craft_crossbow(state: CollectionState, player: int) -> bool: - return state.has('Archery', player) and has_iron_ingots(state, player) +def has_diamond_pickaxe(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return state.has('Progressive Tools', player, 3) and has_iron_ingots(world, state, player) -def has_bottle(state: CollectionState, player: int) -> bool: + +def craft_crossbow(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return state.has('Archery', player) and has_iron_ingots(world, state, player) + + +def has_bottle(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: return state.has('Bottles', player) and state.has('Progressive Resource Crafting', player) -def has_spyglass(state: CollectionState, player: int) -> bool: - return has_copper_ingots(state, player) and state.has('Spyglass', player) and can_adventure(state, player) -def can_enchant(state: CollectionState, player: int) -> bool: - return state.has('Enchanting', player) and has_diamond_pickaxe(state, player) # mine obsidian and lapis +def has_spyglass(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return (has_copper_ingots(world, state, player) + and state.has('Spyglass', player) + and can_adventure(world, state, player) + ) + + +def can_enchant(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return state.has('Enchanting', player) and has_diamond_pickaxe(world, state, player) # mine obsidian and lapis -def can_use_anvil(state: CollectionState, player: int) -> bool: - return state.has('Enchanting', player) and state.has('Progressive Resource Crafting', player, 2) and has_iron_ingots(state, player) -def fortress_loot(state: CollectionState, player: int) -> bool: # saddles, blaze rods, wither skulls - return state.can_reach('Nether Fortress', 'Region', player) and basic_combat(state, player) +def can_use_anvil(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return (state.has('Enchanting', player) + and state.has('Progressive Resource Crafting', player,2) + and has_iron_ingots(world, state, player) + ) -def can_brew_potions(state: CollectionState, player: int) -> bool: - return state.has('Blaze Rods', player) and state.has('Brewing', player) and has_bottle(state, player) -def can_piglin_trade(state: CollectionState, player: int) -> bool: - return has_gold_ingots(state, player) and ( - state.can_reach('The Nether', 'Region', player) or - state.can_reach('Bastion Remnant', 'Region', player)) +def fortress_loot(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: # saddles, blaze rods, wither skulls + return state.can_reach_region('Nether Fortress', player) and basic_combat(world, state, player) -def overworld_villager(state: CollectionState, player: int) -> bool: + +def can_brew_potions(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return state.has('Blaze Rods', player) and state.has('Brewing', player) and has_bottle(world, state, player) + + +def can_piglin_trade(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return (has_gold_ingots(world, state, player) + and ( + state.can_reach_region('The Nether', player) + or state.can_reach_region('Bastion Remnant', player) + )) + + +def overworld_villager(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: village_region = state.multiworld.get_region('Village', player).entrances[0].parent_region.name - if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village - return (state.can_reach('Zombie Doctor', 'Location', player) or - (has_diamond_pickaxe(state, player) and state.can_reach('Village', 'Region', player))) + if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village + return (state.can_reach_location('Zombie Doctor', player) + or ( + has_diamond_pickaxe(world, state, player) + and state.can_reach_region('Village', player) + )) elif village_region == 'The End': - return state.can_reach('Zombie Doctor', 'Location', player) - return state.can_reach('Village', 'Region', player) + return state.can_reach_location('Zombie Doctor', player) + return state.can_reach_region('Village', player) -def enter_stronghold(state: CollectionState, player: int) -> bool: + +def enter_stronghold(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: return state.has('Blaze Rods', player) and state.has('Brewing', player) and state.has('3 Ender Pearls', player) + # Difficulty-dependent functions -def combat_difficulty(state: CollectionState, player: int) -> bool: - return state.multiworld.combat_difficulty[player].current_key - -def can_adventure(state: CollectionState, player: int) -> bool: - death_link_check = not state.multiworld.death_link[player] or state.has('Bed', player) - if combat_difficulty(state, player) == 'easy': - return state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and death_link_check - elif combat_difficulty(state, player) == 'hard': +def combat_difficulty(world: "MinecraftWorld", state: CollectionState, player: int) -> str: + return world.options.combat_difficulty.current_key + + +def can_adventure(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + death_link_check = not world.options.death_link or state.has('Bed', player) + if combat_difficulty(world, state, player) == 'easy': + return state.has('Progressive Weapons', player, 2) and has_iron_ingots(world, state, player) and death_link_check + elif combat_difficulty(world, state, player) == 'hard': return True - return (state.has('Progressive Weapons', player) and death_link_check and - (state.has('Progressive Resource Crafting', player) or state.has('Campfire', player))) - -def basic_combat(state: CollectionState, player: int) -> bool: - if combat_difficulty(state, player) == 'easy': - return state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player) and \ - state.has('Shield', player) and has_iron_ingots(state, player) - elif combat_difficulty(state, player) == 'hard': + return (state.has('Progressive Weapons', player) and death_link_check and + (state.has('Progressive Resource Crafting', player) or state.has('Campfire', player))) + + +def basic_combat(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + if combat_difficulty(world, state, player) == 'easy': + return (state.has('Progressive Weapons', player, 2) + and state.has('Progressive Armor', player) + and state.has('Shield', player) + and has_iron_ingots(world, state, player) + ) + elif combat_difficulty(world, state, player) == 'hard': return True - return state.has('Progressive Weapons', player) and (state.has('Progressive Armor', player) or state.has('Shield', player)) and has_iron_ingots(state, player) - -def complete_raid(state: CollectionState, player: int) -> bool: - reach_regions = state.can_reach('Village', 'Region', player) and state.can_reach('Pillager Outpost', 'Region', player) - if combat_difficulty(state, player) == 'easy': - return reach_regions and \ - state.has('Progressive Weapons', player, 3) and state.has('Progressive Armor', player, 2) and \ - state.has('Shield', player) and state.has('Archery', player) and \ - state.has('Progressive Tools', player, 2) and has_iron_ingots(state, player) - elif combat_difficulty(state, player) == 'hard': # might be too hard? - return reach_regions and state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and \ - (state.has('Progressive Armor', player) or state.has('Shield', player)) - return reach_regions and state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and \ - state.has('Progressive Armor', player) and state.has('Shield', player) - -def can_kill_wither(state: CollectionState, player: int) -> bool: - normal_kill = state.has("Progressive Weapons", player, 3) and state.has("Progressive Armor", player, 2) and can_brew_potions(state, player) and can_enchant(state, player) - if combat_difficulty(state, player) == 'easy': - return fortress_loot(state, player) and normal_kill and state.has('Archery', player) - elif combat_difficulty(state, player) == 'hard': # cheese kill using bedrock ceilings - return fortress_loot(state, player) and (normal_kill or state.can_reach('The Nether', 'Region', player) or state.can_reach('The End', 'Region', player)) - return fortress_loot(state, player) and normal_kill - -def can_respawn_ender_dragon(state: CollectionState, player: int) -> bool: - return state.can_reach('The Nether', 'Region', player) and state.can_reach('The End', 'Region', player) and \ - state.has('Progressive Resource Crafting', player) # smelt sand into glass - -def can_kill_ender_dragon(state: CollectionState, player: int) -> bool: - if combat_difficulty(state, player) == 'easy': - return state.has("Progressive Weapons", player, 3) and state.has("Progressive Armor", player, 2) and \ - state.has('Archery', player) and can_brew_potions(state, player) and can_enchant(state, player) - if combat_difficulty(state, player) == 'hard': - return (state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player)) or \ - (state.has('Progressive Weapons', player, 1) and state.has('Bed', player)) - return state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player) and state.has('Archery', player) - -def has_structure_compass(state: CollectionState, entrance_name: str, player: int) -> bool: - if not state.multiworld.structure_compasses[player]: + return (state.has('Progressive Weapons', player) + and ( + state.has('Progressive Armor', player) + or state.has('Shield', player) + ) + and has_iron_ingots(world, state, player) + ) + + +def complete_raid(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + reach_regions = (state.can_reach_region('Village', player) + and state.can_reach_region('Pillager Outpost', player)) + if combat_difficulty(world, state, player) == 'easy': + return (reach_regions + and state.has('Progressive Weapons', player, 3) + and state.has('Progressive Armor', player, 2) + and state.has('Shield', player) + and state.has('Archery', player) + and state.has('Progressive Tools', player, 2) + and has_iron_ingots(world, state, player) + ) + elif combat_difficulty(world, state, player) == 'hard': # might be too hard? + return (reach_regions + and state.has('Progressive Weapons', player, 2) + and has_iron_ingots(world, state, player) + and ( + state.has('Progressive Armor', player) + or state.has('Shield', player) + ) + ) + return (reach_regions + and state.has('Progressive Weapons', player, 2) + and has_iron_ingots(world, state, player) + and state.has('Progressive Armor', player) + and state.has('Shield', player) + ) + + +def can_kill_wither(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + normal_kill = (state.has("Progressive Weapons", player, 3) + and state.has("Progressive Armor", player, 2) + and can_brew_potions(world, state, player) + and can_enchant(world, state, player) + ) + if combat_difficulty(world, state, player) == 'easy': + return (fortress_loot(world, state, player) + and normal_kill + and state.has('Archery', player) + ) + elif combat_difficulty(world, state, player) == 'hard': # cheese kill using bedrock ceilings + return (fortress_loot(world, state, player) + and ( + normal_kill + or state.can_reach_region('The Nether', player) + or state.can_reach_region('The End', player) + ) + ) + + return fortress_loot(world, state, player) and normal_kill + + +def can_respawn_ender_dragon(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + return (state.can_reach_region('The Nether', player) + and state.can_reach_region('The End', player) + and state.has('Progressive Resource Crafting', player) # smelt sand into glass + ) + + +def can_kill_ender_dragon(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: + if combat_difficulty(world, state, player) == 'easy': + return (state.has("Progressive Weapons", player, 3) + and state.has("Progressive Armor", player, 2) + and state.has('Archery', player) + and can_brew_potions(world, state, player) + and can_enchant(world, state, player) + ) + if combat_difficulty(world, state, player) == 'hard': + return ( + ( + state.has('Progressive Weapons', player, 2) + and state.has('Progressive Armor', player) + ) or ( + state.has('Progressive Weapons', player, 1) + and state.has('Bed', player) # who needs armor when you can respawn right outside the chamber + ) + ) + return (state.has('Progressive Weapons', player, 2) + and state.has('Progressive Armor', player) + and state.has('Archery', player) + ) + + +def has_structure_compass(world: "MinecraftWorld", state: CollectionState, entrance_name: str, player: int) -> bool: + if not world.options.structure_compasses: return True return state.has(f"Structure Compass ({state.multiworld.get_entrance(entrance_name, player).connected_region.name})", player) -def get_rules_lookup(player: int): - rules_lookup: typing.Dict[str, typing.List[Callable[[CollectionState], bool]]] = { +def get_rules_lookup(world, player: int): + rules_lookup = { "entrances": { - "Nether Portal": lambda state: (state.has('Flint and Steel', player) and - (state.has('Bucket', player) or state.has('Progressive Tools', player, 3)) and - has_iron_ingots(state, player)), - "End Portal": lambda state: enter_stronghold(state, player) and state.has('3 Ender Pearls', player, 4), - "Overworld Structure 1": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Overworld Structure 1", player)), - "Overworld Structure 2": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Overworld Structure 2", player)), - "Nether Structure 1": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Nether Structure 1", player)), - "Nether Structure 2": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Nether Structure 2", player)), - "The End Structure": lambda state: (can_adventure(state, player) and has_structure_compass(state, "The End Structure", player)), + "Nether Portal": lambda state: state.has('Flint and Steel', player) + and ( + state.has('Bucket', player) + or state.has('Progressive Tools', player, 3) + ) + and has_iron_ingots(world, state, player), + "End Portal": lambda state: enter_stronghold(world, state, player) + and state.has('3 Ender Pearls', player, 4), + "Overworld Structure 1": lambda state: can_adventure(world, state, player) + and has_structure_compass(world, state, "Overworld Structure 1", player), + "Overworld Structure 2": lambda state: can_adventure(world, state, player) + and has_structure_compass(world, state, "Overworld Structure 2", player), + "Nether Structure 1": lambda state: can_adventure(world, state, player) + and has_structure_compass(world, state, "Nether Structure 1", player), + "Nether Structure 2": lambda state: can_adventure(world, state, player) + and has_structure_compass(world, state, "Nether Structure 2", player), + "The End Structure": lambda state: can_adventure(world, state, player) + and has_structure_compass(world, state, "The End Structure", player), }, "locations": { - "Ender Dragon": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), - "Wither": lambda state: can_kill_wither(state, player), - "Blaze Rods": lambda state: fortress_loot(state, player), - - "Who is Cutting Onions?": lambda state: can_piglin_trade(state, player), - "Oh Shiny": lambda state: can_piglin_trade(state, player), - "Suit Up": lambda state: state.has("Progressive Armor", player) and has_iron_ingots(state, player), - "Very Very Frightening": lambda state: (state.has("Channeling Book", player) and - can_use_anvil(state, player) and can_enchant(state, player) and overworld_villager(state, player)), - "Hot Stuff": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player), - "Free the End": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), - "A Furious Cocktail": lambda state: (can_brew_potions(state, player) and - state.has("Fishing Rod", player) and # Water Breathing - state.can_reach("The Nether", "Region", player) and # Regeneration, Fire Resistance, gold nuggets - state.can_reach("Village", "Region", player) and # Night Vision, Invisibility - state.can_reach("Bring Home the Beacon", "Location", player)), # Resistance - "Bring Home the Beacon": lambda state: (can_kill_wither(state, player) and - has_diamond_pickaxe(state, player) and state.has("Progressive Resource Crafting", player, 2)), - "Not Today, Thank You": lambda state: state.has("Shield", player) and has_iron_ingots(state, player), - "Isn't It Iron Pick": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), - "Local Brewery": lambda state: can_brew_potions(state, player), - "The Next Generation": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "Ender Dragon": lambda state: can_respawn_ender_dragon(world, state, player) + and can_kill_ender_dragon(world, state, player), + "Wither": lambda state: can_kill_wither(world, state, player), + "Blaze Rods": lambda state: fortress_loot(world, state, player), + "Who is Cutting Onions?": lambda state: can_piglin_trade(world, state, player), + "Oh Shiny": lambda state: can_piglin_trade(world, state, player), + "Suit Up": lambda state: state.has("Progressive Armor", player) + and has_iron_ingots(world, state, player), + "Very Very Frightening": lambda state: state.has("Channeling Book", player) + and can_use_anvil(world, state, player) + and can_enchant(world, state, player) + and overworld_villager(world, state, player), + "Hot Stuff": lambda state: state.has("Bucket", player) + and has_iron_ingots(world, state, player), + "Free the End": lambda state: can_respawn_ender_dragon(world, state, player) + and can_kill_ender_dragon(world, state, player), + "A Furious Cocktail": lambda state: (can_brew_potions(world, state, player) + and state.has("Fishing Rod", player) # Water Breathing + and state.can_reach_region("The Nether", player) # Regeneration, Fire Resistance, gold nuggets + and state.can_reach_region("Village", player) # Night Vision, Invisibility + and state.can_reach_location("Bring Home the Beacon", player)), + # Resistance + "Bring Home the Beacon": lambda state: can_kill_wither(world, state, player) + and has_diamond_pickaxe(world, state, player) + and state.has("Progressive Resource Crafting", player, 2), + "Not Today, Thank You": lambda state: state.has("Shield", player) + and has_iron_ingots(world, state, player), + "Isn't It Iron Pick": lambda state: state.has("Progressive Tools", player, 2) + and has_iron_ingots(world, state, player), + "Local Brewery": lambda state: can_brew_potions(world, state, player), + "The Next Generation": lambda state: can_respawn_ender_dragon(world, state, player) + and can_kill_ender_dragon(world, state, player), "Fishy Business": lambda state: state.has("Fishing Rod", player), - "This Boat Has Legs": lambda state: ((fortress_loot(state, player) or complete_raid(state, player)) and - state.has("Saddle", player) and state.has("Fishing Rod", player)), + "This Boat Has Legs": lambda state: ( + fortress_loot(world, state, player) + or complete_raid(world, state, player) + ) + and state.has("Saddle", player) + and state.has("Fishing Rod", player), "Sniper Duel": lambda state: state.has("Archery", player), - "Great View From Up Here": lambda state: basic_combat(state, player), - "How Did We Get Here?": lambda state: (can_brew_potions(state, player) and - has_gold_ingots(state, player) and # Absorption - state.can_reach('End City', 'Region', player) and # Levitation - state.can_reach('The Nether', 'Region', player) and # potion ingredients - state.has("Fishing Rod", player) and state.has("Archery",player) and # Pufferfish, Nautilus Shells; spectral arrows - state.can_reach("Bring Home the Beacon", "Location", player) and # Haste - state.can_reach("Hero of the Village", "Location", player)), # Bad Omen, Hero of the Village - "Bullseye": lambda state: (state.has("Archery", player) and state.has("Progressive Tools", player, 2) and - has_iron_ingots(state, player)), - "Spooky Scary Skeleton": lambda state: basic_combat(state, player), - "Two by Two": lambda state: has_iron_ingots(state, player) and state.has("Bucket", player) and can_adventure(state, player), - "Two Birds, One Arrow": lambda state: craft_crossbow(state, player) and can_enchant(state, player), - "Who's the Pillager Now?": lambda state: craft_crossbow(state, player), + "Great View From Up Here": lambda state: basic_combat(world, state, player), + "How Did We Get Here?": lambda state: (can_brew_potions(world, state, player) + and has_gold_ingots(world, state, player) # Absorption + and state.can_reach_region('End City', player) # Levitation + and state.can_reach_region('The Nether', player) # potion ingredients + and state.has("Fishing Rod", player) # Pufferfish, Nautilus Shells; spectral arrows + and state.has("Archery", player) + and state.can_reach_location("Bring Home the Beacon", player) # Haste + and state.can_reach_location("Hero of the Village", player)), # Bad Omen, Hero of the Village + "Bullseye": lambda state: state.has("Archery", player) + and state.has("Progressive Tools", player, 2) + and has_iron_ingots(world, state, player), + "Spooky Scary Skeleton": lambda state: basic_combat(world, state, player), + "Two by Two": lambda state: has_iron_ingots(world, state, player) + and state.has("Bucket", player) + and can_adventure(world, state, player), + "Two Birds, One Arrow": lambda state: craft_crossbow(world, state, player) + and can_enchant(world, state, player), + "Who's the Pillager Now?": lambda state: craft_crossbow(world, state, player), "Getting an Upgrade": lambda state: state.has("Progressive Tools", player), - "Tactical Fishing": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player), - "Zombie Doctor": lambda state: can_brew_potions(state, player) and has_gold_ingots(state, player), - "Ice Bucket Challenge": lambda state: has_diamond_pickaxe(state, player), - "Into Fire": lambda state: basic_combat(state, player), - "War Pigs": lambda state: basic_combat(state, player), + "Tactical Fishing": lambda state: state.has("Bucket", player) + and has_iron_ingots(world, state, player), + "Zombie Doctor": lambda state: can_brew_potions(world, state, player) + and has_gold_ingots(world, state, player), + "Ice Bucket Challenge": lambda state: has_diamond_pickaxe(world, state, player), + "Into Fire": lambda state: basic_combat(world, state, player), + "War Pigs": lambda state: basic_combat(world, state, player), "Take Aim": lambda state: state.has("Archery", player), - "Total Beelocation": lambda state: state.has("Silk Touch Book", player) and can_use_anvil(state, player) and can_enchant(state, player), - "Arbalistic": lambda state: (craft_crossbow(state, player) and state.has("Piercing IV Book", player) and - can_use_anvil(state, player) and can_enchant(state, player)), - "The End... Again...": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), - "Acquire Hardware": lambda state: has_iron_ingots(state, player), - "Not Quite \"Nine\" Lives": lambda state: can_piglin_trade(state, player) and state.has("Progressive Resource Crafting", player, 2), - "Cover Me With Diamonds": lambda state: (state.has("Progressive Armor", player, 2) and - state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player)), - "Sky's the Limit": lambda state: basic_combat(state, player), - "Hired Help": lambda state: state.has("Progressive Resource Crafting", player, 2) and has_iron_ingots(state, player), - "Sweet Dreams": lambda state: state.has("Bed", player) or state.can_reach('Village', 'Region', player), - "You Need a Mint": lambda state: can_respawn_ender_dragon(state, player) and has_bottle(state, player), - "Monsters Hunted": lambda state: (can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player) and - can_kill_wither(state, player) and state.has("Fishing Rod", player)), - "Enchanter": lambda state: can_enchant(state, player), - "Voluntary Exile": lambda state: basic_combat(state, player), - "Eye Spy": lambda state: enter_stronghold(state, player), - "Serious Dedication": lambda state: (can_brew_potions(state, player) and state.has("Bed", player) and - has_diamond_pickaxe(state, player) and has_gold_ingots(state, player)), - "Postmortal": lambda state: complete_raid(state, player), - "Adventuring Time": lambda state: can_adventure(state, player), - "Hero of the Village": lambda state: complete_raid(state, player), - "Hidden in the Depths": lambda state: can_brew_potions(state, player) and state.has("Bed", player) and has_diamond_pickaxe(state, player), - "Beaconator": lambda state: (can_kill_wither(state, player) and has_diamond_pickaxe(state, player) and - state.has("Progressive Resource Crafting", player, 2)), - "Withering Heights": lambda state: can_kill_wither(state, player), - "A Balanced Diet": lambda state: (has_bottle(state, player) and has_gold_ingots(state, player) and # honey bottle; gapple - state.has("Progressive Resource Crafting", player, 2) and state.can_reach('The End', 'Region', player)), # notch apple, chorus fruit - "Subspace Bubble": lambda state: has_diamond_pickaxe(state, player), - "Country Lode, Take Me Home": lambda state: state.can_reach("Hidden in the Depths", "Location", player) and has_gold_ingots(state, player), - "Bee Our Guest": lambda state: state.has("Campfire", player) and has_bottle(state, player), - "Uneasy Alliance": lambda state: has_diamond_pickaxe(state, player) and state.has('Fishing Rod', player), - "Diamonds!": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), - "A Throwaway Joke": lambda state: can_adventure(state, player), - "Sticky Situation": lambda state: state.has("Campfire", player) and has_bottle(state, player), - "Ol' Betsy": lambda state: craft_crossbow(state, player), - "Cover Me in Debris": lambda state: (state.has("Progressive Armor", player, 2) and - state.has("8 Netherite Scrap", player, 2) and state.has("Progressive Resource Crafting", player) and - has_diamond_pickaxe(state, player) and has_iron_ingots(state, player) and - can_brew_potions(state, player) and state.has("Bed", player)), + "Total Beelocation": lambda state: state.has("Silk Touch Book", player) + and can_use_anvil(world, state, player) + and can_enchant(world, state, player), + "Arbalistic": lambda state: (craft_crossbow(world, state, player) + and state.has("Piercing IV Book", player) + and can_use_anvil(world, state, player) + and can_enchant(world, state, player) + ), + "The End... Again...": lambda state: can_respawn_ender_dragon(world, state, player) + and can_kill_ender_dragon(world, state, player), + "Acquire Hardware": lambda state: has_iron_ingots(world, state, player), + "Not Quite \"Nine\" Lives": lambda state: can_piglin_trade(world, state, player) + and state.has("Progressive Resource Crafting", player, 2), + "Cover Me With Diamonds": lambda state: state.has("Progressive Armor", player, 2) + and state.has("Progressive Tools", player, 2) + and has_iron_ingots(world, state, player), + "Sky's the Limit": lambda state: basic_combat(world, state, player), + "Hired Help": lambda state: state.has("Progressive Resource Crafting", player, 2) + and has_iron_ingots(world, state, player), + "Sweet Dreams": lambda state: state.has("Bed", player) + or state.can_reach_region('Village', player), + "You Need a Mint": lambda state: can_respawn_ender_dragon(world, state, player) + and has_bottle(world, state, player), + "Monsters Hunted": lambda state: (can_respawn_ender_dragon(world, state, player) + and can_kill_ender_dragon(world, state, player) + and can_kill_wither(world, state, player) + and state.has("Fishing Rod", player)), + "Enchanter": lambda state: can_enchant(world, state, player), + "Voluntary Exile": lambda state: basic_combat(world, state, player), + "Eye Spy": lambda state: enter_stronghold(world, state, player), + "Serious Dedication": lambda state: (can_brew_potions(world, state, player) + and state.has("Bed", player) + and has_diamond_pickaxe(world, state, player) + and has_gold_ingots(world, state, player)), + "Postmortal": lambda state: complete_raid(world, state, player), + "Adventuring Time": lambda state: can_adventure(world, state, player), + "Hero of the Village": lambda state: complete_raid(world, state, player), + "Hidden in the Depths": lambda state: can_brew_potions(world, state, player) + and state.has("Bed", player) + and has_diamond_pickaxe(world, state, player), + "Beaconator": lambda state: (can_kill_wither(world, state, player) + and has_diamond_pickaxe(world, state, player) + and state.has("Progressive Resource Crafting", player, 2)), + "Withering Heights": lambda state: can_kill_wither(world, state, player), + "A Balanced Diet": lambda state: (has_bottle(world, state, player) + and has_gold_ingots(world, state, player) + and state.has("Progressive Resource Crafting", player, 2) + and state.can_reach_region('The End', player)), + # notch apple, chorus fruit + "Subspace Bubble": lambda state: has_diamond_pickaxe(world, state, player), + "Country Lode, Take Me Home": lambda state: state.can_reach_location("Hidden in the Depths", player) + and has_gold_ingots(world, state, player), + "Bee Our Guest": lambda state: state.has("Campfire", player) + and has_bottle(world, state, player), + "Uneasy Alliance": lambda state: has_diamond_pickaxe(world, state, player) + and state.has('Fishing Rod', player), + "Diamonds!": lambda state: state.has("Progressive Tools", player, 2) + and has_iron_ingots(world, state, player), + "A Throwaway Joke": lambda state: can_adventure(world, state, player), + "Sticky Situation": lambda state: state.has("Campfire", player) + and has_bottle(world, state, player), + "Ol' Betsy": lambda state: craft_crossbow(world, state, player), + "Cover Me in Debris": lambda state: state.has("Progressive Armor", player, 2) + and state.has("8 Netherite Scrap", player, 2) + and state.has("Progressive Resource Crafting", player) + and has_diamond_pickaxe(world, state, player) + and has_iron_ingots(world, state, player) + and can_brew_potions(world, state, player) + and state.has("Bed", player), "Hot Topic": lambda state: state.has("Progressive Resource Crafting", player), - "The Lie": lambda state: has_iron_ingots(state, player) and state.has("Bucket", player), - "On a Rail": lambda state: has_iron_ingots(state, player) and state.has('Progressive Tools', player, 2), - "When Pigs Fly": lambda state: ((fortress_loot(state, player) or complete_raid(state, player)) and - state.has("Saddle", player) and state.has("Fishing Rod", player) and can_adventure(state, player)), - "Overkill": lambda state: (can_brew_potions(state, player) and - (state.has("Progressive Weapons", player) or state.can_reach('The Nether', 'Region', player))), + "The Lie": lambda state: has_iron_ingots(world, state, player) + and state.has("Bucket", player), + "On a Rail": lambda state: has_iron_ingots(world, state, player) + and state.has('Progressive Tools', player, 2), + "When Pigs Fly": lambda state: ( + fortress_loot(world, state, player) + or complete_raid(world, state, player) + ) + and state.has("Saddle", player) + and state.has("Fishing Rod", player) + and can_adventure(world, state, player), + "Overkill": lambda state: can_brew_potions(world, state, player) + and ( + state.has("Progressive Weapons", player) + or state.can_reach_region('The Nether', player) + ), "Librarian": lambda state: state.has("Enchanting", player), - "Overpowered": lambda state: (has_iron_ingots(state, player) and - state.has('Progressive Tools', player, 2) and basic_combat(state, player)), - "Wax On": lambda state: (has_copper_ingots(state, player) and state.has('Campfire', player) and - state.has('Progressive Resource Crafting', player, 2)), - "Wax Off": lambda state: (has_copper_ingots(state, player) and state.has('Campfire', player) and - state.has('Progressive Resource Crafting', player, 2)), - "The Cutest Predator": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player), - "The Healing Power of Friendship": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player), - "Is It a Bird?": lambda state: has_spyglass(state, player) and can_adventure(state, player), - "Is It a Balloon?": lambda state: has_spyglass(state, player), - "Is It a Plane?": lambda state: has_spyglass(state, player) and can_respawn_ender_dragon(state, player), - "Surge Protector": lambda state: (state.has("Channeling Book", player) and - can_use_anvil(state, player) and can_enchant(state, player) and overworld_villager(state, player)), - "Light as a Rabbit": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has('Bucket', player), - "Glow and Behold!": lambda state: can_adventure(state, player), - "Whatever Floats Your Goat!": lambda state: can_adventure(state, player), - "Caves & Cliffs": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player) and state.has('Progressive Tools', player, 2), - "Feels like home": lambda state: (has_iron_ingots(state, player) and state.has('Bucket', player) and state.has('Fishing Rod', player) and - (fortress_loot(state, player) or complete_raid(state, player)) and state.has("Saddle", player)), - "Sound of Music": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player) and basic_combat(state, player), - "Star Trader": lambda state: (has_iron_ingots(state, player) and state.has('Bucket', player) and - (state.can_reach("The Nether", 'Region', player) or - state.can_reach("Nether Fortress", 'Region', player) or # soul sand for water elevator - can_piglin_trade(state, player)) and - overworld_villager(state, player)), - "Birthday Song": lambda state: state.can_reach("The Lie", "Location", player) and state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), - "Bukkit Bukkit": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player) and can_adventure(state, player), - "It Spreads": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has("Progressive Tools", player, 2), - "Sneak 100": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has("Progressive Tools", player, 2), - "When the Squad Hops into Town": lambda state: can_adventure(state, player) and state.has("Lead", player), - "With Our Powers Combined!": lambda state: can_adventure(state, player) and state.has("Lead", player), + "Overpowered": lambda state: has_iron_ingots(world, state, player) + and state.has('Progressive Tools', player, 2) + and basic_combat(world, state, player), + "Wax On": lambda state: has_copper_ingots(world, state, player) + and state.has('Campfire', player) + and state.has('Progressive Resource Crafting', player, 2), + "Wax Off": lambda state: has_copper_ingots(world, state, player) + and state.has('Campfire', player) + and state.has('Progressive Resource Crafting', player, 2), + "The Cutest Predator": lambda state: has_iron_ingots(world, state, player) + and state.has('Bucket', player), + "The Healing Power of Friendship": lambda state: has_iron_ingots(world, state, player) + and state.has('Bucket', player), + "Is It a Bird?": lambda state: has_spyglass(world, state, player) + and can_adventure(world, state, player), + "Is It a Balloon?": lambda state: has_spyglass(world, state, player), + "Is It a Plane?": lambda state: has_spyglass(world, state, player) + and can_respawn_ender_dragon(world, state, player), + "Surge Protector": lambda state: state.has("Channeling Book", player) + and can_use_anvil(world, state, player) + and can_enchant(world, state, player) + and overworld_villager(world, state, player), + "Light as a Rabbit": lambda state: can_adventure(world, state, player) + and has_iron_ingots(world, state, player) + and state.has('Bucket', player), + "Glow and Behold!": lambda state: can_adventure(world, state, player), + "Whatever Floats Your Goat!": lambda state: can_adventure(world, state, player), + "Caves & Cliffs": lambda state: has_iron_ingots(world, state, player) + and state.has('Bucket', player) + and state.has('Progressive Tools', player, 2), + "Feels like home": lambda state: has_iron_ingots(world, state, player) + and state.has('Bucket', player) + and state.has('Fishing Rod', player) + and ( + fortress_loot(world, state, player) + or complete_raid(world, state, player) + ) + and state.has("Saddle", player), + "Sound of Music": lambda state: state.has("Progressive Tools", player, 2) + and has_iron_ingots(world, state, player) + and basic_combat(world, state, player), + "Star Trader": lambda state: has_iron_ingots(world, state, player) + and state.has('Bucket', player) + and ( + state.can_reach_region("The Nether", player) # soul sand in nether + or state.can_reach_region("Nether Fortress", player) # soul sand in fortress if not in nether for water elevator + or can_piglin_trade(world, state, player) # piglins give soul sand + ) + and overworld_villager(world, state, player), + "Birthday Song": lambda state: state.can_reach_location("The Lie", player) + and state.has("Progressive Tools", player, 2) + and has_iron_ingots(world, state, player), + "Bukkit Bukkit": lambda state: state.has("Bucket", player) + and has_iron_ingots(world, state, player) + and can_adventure(world, state, player), + "It Spreads": lambda state: can_adventure(world, state, player) + and has_iron_ingots(world, state, player) + and state.has("Progressive Tools", player, 2), + "Sneak 100": lambda state: can_adventure(world, state, player) + and has_iron_ingots(world, state, player) + and state.has("Progressive Tools", player, 2), + "When the Squad Hops into Town": lambda state: can_adventure(world, state, player) + and state.has("Lead", player), + "With Our Powers Combined!": lambda state: can_adventure(world, state, player) + and state.has("Lead", player), } } return rules_lookup -def set_rules(mc_world: World) -> None: - multiworld = mc_world.multiworld - player = mc_world.player +def set_rules(self: "MinecraftWorld") -> None: + multiworld = self.multiworld + player = self.player - rules_lookup = get_rules_lookup(player) + rules_lookup = get_rules_lookup(self, player) # Set entrance rules for entrance_name, rule in rules_lookup["entrances"].items(): @@ -281,33 +476,33 @@ def set_rules(mc_world: World) -> None: multiworld.get_location(location_name, player).access_rule = rule # Set rules surrounding completion - bosses = multiworld.required_bosses[player] + bosses = self.options.required_bosses postgame_advancements = set() if bosses.dragon: postgame_advancements.update(Constants.exclusion_info["ender_dragon"]) if bosses.wither: postgame_advancements.update(Constants.exclusion_info["wither"]) - def location_count(state: CollectionState) -> bool: + def location_count(state: CollectionState) -> int: return len([location for location in multiworld.get_locations(player) if - location.address != None and - location.can_reach(state)]) + location.address is not None and + location.can_reach(state)]) def defeated_bosses(state: CollectionState) -> bool: return ((not bosses.dragon or state.has("Ender Dragon", player)) - and (not bosses.wither or state.has("Wither", player))) + and (not bosses.wither or state.has("Wither", player))) - egg_shards = min(multiworld.egg_shards_required[player], multiworld.egg_shards_available[player]) - completion_requirements = lambda state: (location_count(state) >= multiworld.advancement_goal[player] - and state.has("Dragon Egg Shard", player, egg_shards)) + egg_shards = min(self.options.egg_shards_required.value, self.options.egg_shards_available.value) + completion_requirements = lambda state: (location_count(state) >= self.options.advancement_goal + and state.has("Dragon Egg Shard", player, egg_shards)) multiworld.completion_condition[player] = lambda state: completion_requirements(state) and defeated_bosses(state) # Set exclusions on hard/unreasonable/postgame excluded_advancements = set() - if not multiworld.include_hard_advancements[player]: + if not self.options.include_hard_advancements: excluded_advancements.update(Constants.exclusion_info["hard"]) - if not multiworld.include_unreasonable_advancements[player]: + if not self.options.include_unreasonable_advancements: excluded_advancements.update(Constants.exclusion_info["unreasonable"]) - if not multiworld.include_postgame_advancements[player]: + if not self.options.include_postgame_advancements: excluded_advancements.update(postgame_advancements) exclusion_rules(multiworld, player, excluded_advancements) diff --git a/worlds/minecraft/Structures.py b/worlds/minecraft/Structures.py index 95bafc9efb5c..df3d944a6c65 100644 --- a/worlds/minecraft/Structures.py +++ b/worlds/minecraft/Structures.py @@ -1,17 +1,19 @@ -from worlds.AutoWorld import World - from . import Constants +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from . import MinecraftWorld + -def shuffle_structures(mc_world: World) -> None: - multiworld = mc_world.multiworld - player = mc_world.player +def shuffle_structures(self: "MinecraftWorld") -> None: + multiworld = self.multiworld + player = self.player default_connections = Constants.region_info["default_connections"] illegal_connections = Constants.region_info["illegal_connections"] # Get all unpaired exits and all regions without entrances (except the Menu) # This function is destructive on these lists. - exits = [exit.name for r in multiworld.regions if r.player == player for exit in r.exits if exit.connected_region == None] + exits = [exit.name for r in multiworld.regions if r.player == player for exit in r.exits if exit.connected_region is None] structs = [r.name for r in multiworld.regions if r.player == player and r.entrances == [] and r.name != 'Menu'] exits_spoiler = exits[:] # copy the original order for the spoiler log @@ -26,19 +28,19 @@ def set_pair(exit, struct): raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({multiworld.player_name[player]})") # Connect plando structures first - if multiworld.plando_connections[player]: - for conn in multiworld.plando_connections[player]: + if self.options.plando_connections: + for conn in self.plando_connections: set_pair(conn.entrance, conn.exit) # The algorithm tries to place the most restrictive structures first. This algorithm always works on the # relatively small set of restrictions here, but does not work on all possible inputs with valid configurations. - if multiworld.shuffle_structures[player]: + if self.options.shuffle_structures: structs.sort(reverse=True, key=lambda s: len(illegal_connections.get(s, []))) for struct in structs[:]: try: - exit = multiworld.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])]) + exit = self.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])]) except IndexError: - raise Exception(f"No valid structure placements remaining for player {player} ({multiworld.player_name[player]})") + raise Exception(f"No valid structure placements remaining for player {player} ({self.player_name})") set_pair(exit, struct) else: # write remaining default connections for (exit, struct) in default_connections: @@ -49,9 +51,9 @@ def set_pair(exit, struct): try: assert len(exits) == len(structs) == 0 except AssertionError: - raise Exception(f"Failed to connect all Minecraft structures for player {player} ({multiworld.player_name[player]})") + raise Exception(f"Failed to connect all Minecraft structures for player {player} ({self.player_name})") for exit in exits_spoiler: multiworld.get_entrance(exit, player).connect(multiworld.get_region(pairs[exit], player)) - if multiworld.shuffle_structures[player] or multiworld.plando_connections[player]: + if self.options.shuffle_structures or self.options.plando_connections: multiworld.spoiler.set_entrance(exit, pairs[exit], 'entrance', player) diff --git a/worlds/minecraft/__init__.py b/worlds/minecraft/__init__.py index 75e043d0cbaf..75539fcf2ea6 100644 --- a/worlds/minecraft/__init__.py +++ b/worlds/minecraft/__init__.py @@ -9,7 +9,7 @@ from worlds.AutoWorld import World, WebWorld from . import Constants -from .Options import minecraft_options +from .Options import MinecraftOptions from .Structures import shuffle_structures from .ItemPool import build_item_pool, get_junk_item_names from .Rules import set_rules @@ -83,8 +83,9 @@ class MinecraftWorld(World): structures, and materials to create a portal to another world. Defeat the Ender Dragon, and claim victory! """ - game: str = "Minecraft" - option_definitions = minecraft_options + game = "Minecraft" + options_dataclass = MinecraftOptions + options: MinecraftOptions settings: typing.ClassVar[MinecraftSettings] topology_present = True web = MinecraftWebWorld() @@ -95,20 +96,20 @@ class MinecraftWorld(World): def _get_mc_data(self) -> Dict[str, Any]: exits = [connection[0] for connection in Constants.region_info["default_connections"]] return { - 'world_seed': self.multiworld.per_slot_randoms[self.player].getrandbits(32), + 'world_seed': self.random.getrandbits(32), 'seed_name': self.multiworld.seed_name, - 'player_name': self.multiworld.get_player_name(self.player), + 'player_name': self.player_name, 'player_id': self.player, 'client_version': client_version, 'structures': {exit: self.multiworld.get_entrance(exit, self.player).connected_region.name for exit in exits}, - 'advancement_goal': self.multiworld.advancement_goal[self.player].value, - 'egg_shards_required': min(self.multiworld.egg_shards_required[self.player].value, - self.multiworld.egg_shards_available[self.player].value), - 'egg_shards_available': self.multiworld.egg_shards_available[self.player].value, - 'required_bosses': self.multiworld.required_bosses[self.player].current_key, - 'MC35': bool(self.multiworld.send_defeated_mobs[self.player].value), - 'death_link': bool(self.multiworld.death_link[self.player].value), - 'starting_items': str(self.multiworld.starting_items[self.player].value), + 'advancement_goal': self.options.advancement_goal.value, + 'egg_shards_required': min(self.options.egg_shards_required.value, + self.options.egg_shards_available.value), + 'egg_shards_available': self.options.egg_shards_available.value, + 'required_bosses': self.options.required_bosses.current_key, + 'MC35': bool(self.options.send_defeated_mobs.value), + 'death_link': bool(self.options.death_link.value), + 'starting_items': json.dumps(self.options.starting_items.value), 'race': self.multiworld.is_race, } @@ -129,7 +130,7 @@ def create_event(self, region_name: str, event_name: str) -> None: loc.place_locked_item(self.create_event_item(event_name)) region.locations.append(loc) - def create_event_item(self, name: str) -> None: + def create_event_item(self, name: str) -> Item: item = self.create_item(name) item.classification = ItemClassification.progression return item @@ -176,15 +177,10 @@ def generate_output(self, output_directory: str) -> None: f.write(b64encode(bytes(json.dumps(data), 'utf-8'))) def fill_slot_data(self) -> dict: - slot_data = self._get_mc_data() - for option_name in minecraft_options: - option = getattr(self.multiworld, option_name)[self.player] - if slot_data.get(option_name, None) is None and type(option.value) in {str, int}: - slot_data[option_name] = int(option.value) - return slot_data + return self._get_mc_data() def get_filler_item_name(self) -> str: - return get_junk_item_names(self.multiworld.random, 1)[0] + return get_junk_item_names(self.random, 1)[0] class MinecraftLocation(Location): diff --git a/worlds/minecraft/test/TestOptions.py b/worlds/minecraft/test/TestOptions.py index 668ed500e832..c04a07054c9c 100644 --- a/worlds/minecraft/test/TestOptions.py +++ b/worlds/minecraft/test/TestOptions.py @@ -1,19 +1,19 @@ from . import MCTestBase from ..Constants import region_info -from ..Options import minecraft_options +from .. import Options from BaseClasses import ItemClassification class AdvancementTestBase(MCTestBase): options = { - "advancement_goal": minecraft_options["advancement_goal"].range_end + "advancement_goal": Options.AdvancementGoal.range_end } # beatability test implicit class ShardTestBase(MCTestBase): options = { - "egg_shards_required": minecraft_options["egg_shards_required"].range_end, - "egg_shards_available": minecraft_options["egg_shards_available"].range_end + "egg_shards_required": Options.EggShardsRequired.range_end, + "egg_shards_available": Options.EggShardsAvailable.range_end } # check that itempool is not overfilled with shards @@ -29,7 +29,7 @@ def test_compasses_in_pool(self): class NoBeeTestBase(MCTestBase): options = { - "bee_traps": 0 + "bee_traps": Options.BeeTraps.range_start } # With no bees, there are no traps in the pool @@ -40,7 +40,7 @@ def test_bees(self): class AllBeeTestBase(MCTestBase): options = { - "bee_traps": 100 + "bee_traps": Options.BeeTraps.range_end } # With max bees, there are no filler items, only bee traps diff --git a/worlds/minecraft/test/__init__.py b/worlds/minecraft/test/__init__.py index acf9b7949137..3d936fe9cb6b 100644 --- a/worlds/minecraft/test/__init__.py +++ b/worlds/minecraft/test/__init__.py @@ -1,5 +1,5 @@ -from test.TestBase import TestBase, WorldTestBase -from .. import MinecraftWorld +from test.bases import TestBase, WorldTestBase +from .. import MinecraftWorld, MinecraftOptions class MCTestBase(WorldTestBase, TestBase): From f253dffc0708fb8f3a2fb53bd003c2895ec39e97 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 20 Aug 2024 01:16:35 +0200 Subject: [PATCH 3/5] The Witness: Panel Hunt Mode (#3265) * Add panel hunt options * Make sure all panels are either solvable or disabled in panel hunt * Pick huntable panels * Discards in disable non randomized * Set up panel hunt requirement * Panel hunt functional * Make it so an event can have multiple names * Panel hunt with events * Add hunt entities to slot data * ruff * add to hint data, no client sneding yet * encode panel hunt amount in compact hint data * Remove print statement * my b * consistent * meh * additions for lcient * Nah * Victory panels ineligible for panel hunt * Panel Hunt Postgame option * cleanup * Add data generation file * pull out set * always disable gate ep in panel hunt * Disallow certain challenge panels from being panel hunt panels * Make panelhuntpostgame its own function, so it can be called even if normal postgame is enabled * disallow PP resets from panel hunt * Disable challenge timer and elevetor start respectively in disable hunt postgame * Fix panelhunt postgame * lol * When you test that the bug is fixed but not that the non-bug is not unfixed * Prevent Obelisks from being panel hunt panels * Make picking panels for panel hunt a bit more sophisticated, if less random * Better function maybe ig * Ok maybe that was a bit too much * Give advanced players some control over panel hunt * lint * correct the logic for amount to pick * decided the jingle thing was dumb, I'll figure sth out client side. Same area discouragement is now a configurable factor, and the logic has been significantly rewritten * comment * Make the option visible * Safety * Change assert slightly * We do a little logging * number tweak & we do a lil logging * we do a little more logging * Ruff * Panel Hunt Option Group * Idk how that got here * Update worlds/witness/options.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update worlds/witness/__init__.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * remove merge error * Update worlds/witness/player_logic.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * True * Don't have underwater sliding bridge when you have above water sliding bridge * These are not actually connected lol * get rid of unnecessary variable * Refactor compact hint function again * lint * Pull out Entity Hunt Picking into its own class, split it into many functions. Kept a lot of the comments tho * forgot to actually add the new file * some more refactoring & docstrings * consistent naming * flip elif change * Comment about naming * Make static eligible panels a constant I can refer back to * slight formatting change * pull out options-based eligibility into its own function * better text and stuff * lint * this is not necessary * capitalisation * Fix same area discouragement 0 * Simplify data file generation * Simplify data file generation * prevent div 0 * Add Vault Boxes -> Vault Panels to replacements * Update options.py * Update worlds/witness/entity_hunt.py Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * Update entity_hunt.py * Fix some events not working * assert * remove now unused function * lint * Lasers Activate, Lasers don't Solve * lint * oops * mypy * lint * Add simple panel hunt unit test * Add Panel Hunt Tests * Add more Panel Hunt Tests * Disallow Box Short for normal panel hunt --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- worlds/witness/__init__.py | 25 +- worlds/witness/data/settings/Entity_Hunt.txt | 6 + worlds/witness/data/static_locations.py | 4 + worlds/witness/data/static_logic.py | 23 +- worlds/witness/data/utils.py | 4 + worlds/witness/entity_hunt.py | 234 +++++++++++++++++++ worlds/witness/generate_data_file.py | 45 ++++ worlds/witness/hints.py | 93 +++++--- worlds/witness/locations.py | 8 +- worlds/witness/options.py | 67 ++++++ worlds/witness/player_items.py | 2 +- worlds/witness/player_logic.py | 145 +++++++++--- worlds/witness/regions.py | 17 +- worlds/witness/rules.py | 55 +++-- worlds/witness/test/__init__.py | 33 +++ worlds/witness/test/test_panel_hunt.py | 107 +++++++++ 16 files changed, 751 insertions(+), 117 deletions(-) create mode 100644 worlds/witness/data/settings/Entity_Hunt.txt create mode 100644 worlds/witness/entity_hunt.py create mode 100644 worlds/witness/generate_data_file.py create mode 100644 worlds/witness/test/test_panel_hunt.py diff --git a/worlds/witness/__init__.py b/worlds/witness/__init__.py index 254064098db9..b228842019cf 100644 --- a/worlds/witness/__init__.py +++ b/worlds/witness/__init__.py @@ -15,7 +15,7 @@ from .data import static_logic as static_witness_logic from .data.item_definition_classes import DoorItemDefinition, ItemData from .data.utils import get_audio_logs -from .hints import CompactItemData, create_all_hints, make_compact_hint_data, make_laser_hints +from .hints import CompactHintData, create_all_hints, make_compact_hint_data, make_laser_hints from .locations import WitnessPlayerLocations from .options import TheWitnessOptions, witness_option_groups from .player_items import WitnessItem, WitnessPlayerItems @@ -68,12 +68,14 @@ class WitnessWorld(World): player_items: WitnessPlayerItems player_regions: WitnessPlayerRegions - log_ids_to_hints: Dict[int, CompactItemData] - laser_ids_to_hints: Dict[int, CompactItemData] + log_ids_to_hints: Dict[int, CompactHintData] + laser_ids_to_hints: Dict[int, CompactHintData] items_placed_early: List[str] own_itempool: List[WitnessItem] + panel_hunt_required_count: int + def _get_slot_data(self) -> Dict[str, Any]: return { "seed": self.random.randrange(0, 1000000), @@ -83,12 +85,14 @@ def _get_slot_data(self) -> Dict[str, Any]: "door_hexes_in_the_pool": self.player_items.get_door_ids_in_pool(), "symbols_not_in_the_game": self.player_items.get_symbol_ids_not_in_pool(), "disabled_entities": [int(h, 16) for h in self.player_logic.COMPLETELY_DISABLED_ENTITIES], + "hunt_entities": [int(h, 16) for h in self.player_logic.HUNT_ENTITIES], "log_ids_to_hints": self.log_ids_to_hints, "laser_ids_to_hints": self.laser_ids_to_hints, "progressive_item_lists": self.player_items.get_progressive_item_ids_in_pool(), "obelisk_side_id_to_EPs": static_witness_logic.OBELISK_SIDE_ID_TO_EP_HEXES, "precompleted_puzzles": [int(h, 16) for h in self.player_logic.EXCLUDED_LOCATIONS], "entity_to_name": static_witness_logic.ENTITY_ID_TO_NAME, + "panel_hunt_required_absolute": self.panel_hunt_required_count } def determine_sufficient_progression(self) -> None: @@ -151,6 +155,13 @@ def generate_early(self) -> None: if self.options.shuffle_lasers == "local": self.options.local_items.value |= self.item_name_groups["Lasers"] + if self.options.victory_condition == "panel_hunt": + total_panels = self.options.panel_hunt_total + required_percentage = self.options.panel_hunt_required_percentage + self.panel_hunt_required_count = round(total_panels * required_percentage / 100) + else: + self.panel_hunt_required_count = 0 + def create_regions(self) -> None: self.player_regions.create_regions(self, self.player_logic) @@ -169,7 +180,7 @@ def create_regions(self) -> None: for event_location in self.player_locations.EVENT_LOCATION_TABLE: item_obj = self.create_item( - self.player_logic.EVENT_ITEM_PAIRS[event_location] + self.player_logic.EVENT_ITEM_PAIRS[event_location][0] ) location_obj = self.get_location(event_location) location_obj.place_locked_item(item_obj) @@ -192,7 +203,7 @@ def create_regions(self) -> None: ] if early_items: random_early_item = self.random.choice(early_items) - if self.options.puzzle_randomization == "sigma_expert": + if self.options.puzzle_randomization == "sigma_expert" or self.options.victory_condition == "panel_hunt": # In Expert, only tag the item as early, rather than forcing it onto the gate. self.multiworld.local_early_items[self.player][random_early_item] = 1 else: @@ -305,8 +316,8 @@ def create_items(self) -> None: self.options.local_items.value.add(item_name) def fill_slot_data(self) -> Dict[str, Any]: - self.log_ids_to_hints: Dict[int, CompactItemData] = {} - self.laser_ids_to_hints: Dict[int, CompactItemData] = {} + self.log_ids_to_hints: Dict[int, CompactHintData] = {} + self.laser_ids_to_hints: Dict[int, CompactHintData] = {} already_hinted_locations = set() diff --git a/worlds/witness/data/settings/Entity_Hunt.txt b/worlds/witness/data/settings/Entity_Hunt.txt new file mode 100644 index 000000000000..4135dbd842f7 --- /dev/null +++ b/worlds/witness/data/settings/Entity_Hunt.txt @@ -0,0 +1,6 @@ +Requirement Changes: +0x03629 - Entity Hunt - True +0x03505 - 0x03629 - True + +New Connections: +Tutorial - Outside Tutorial - True diff --git a/worlds/witness/data/static_locations.py b/worlds/witness/data/static_locations.py index de321d20c0f9..d9566080a04c 100644 --- a/worlds/witness/data/static_locations.py +++ b/worlds/witness/data/static_locations.py @@ -406,6 +406,10 @@ "Mountain Bottom Floor Discard", } +GENERAL_LOCATION_HEXES = { + static_witness_logic.ENTITIES_BY_NAME[entity_name]["entity_hex"] for entity_name in GENERAL_LOCATIONS +} + OBELISK_SIDES = { "Desert Obelisk Side 1", "Desert Obelisk Side 2", diff --git a/worlds/witness/data/static_logic.py b/worlds/witness/data/static_logic.py index a9175c0c30b3..b61b0f9d2f92 100644 --- a/worlds/witness/data/static_logic.py +++ b/worlds/witness/data/static_logic.py @@ -103,6 +103,7 @@ def read_logic_file(self, lines: List[str]) -> None: "region": None, "id": None, "entityType": location_id, + "locationType": None, "area": current_area, } @@ -127,19 +128,30 @@ def read_logic_file(self, lines: List[str]) -> None: "Laser Hedges", "Laser Pressure Plates", } - is_vault_or_video = "Vault" in entity_name or "Video" in entity_name if "Discard" in entity_name: + entity_type = "Panel" location_type = "Discard" - elif is_vault_or_video or entity_name == "Tutorial Gate Close": + elif "Vault" in entity_name: + entity_type = "Panel" location_type = "Vault" elif entity_name in laser_names: - location_type = "Laser" + entity_type = "Laser" + location_type = None elif "Obelisk Side" in entity_name: + entity_type = "Obelisk Side" location_type = "Obelisk Side" + elif "Obelisk" in entity_name: + entity_type = "Obelisk" + location_type = None elif "EP" in entity_name: + entity_type = "EP" location_type = "EP" + elif entity_hex.startswith("0xFF"): + entity_type = "Event" + location_type = None else: + entity_type = "Panel" location_type = "General" required_items = parse_lambda(required_item_lambda) @@ -152,7 +164,7 @@ def read_logic_file(self, lines: List[str]) -> None: "items": required_items } - if location_type == "Obelisk Side": + if entity_type == "Obelisk Side": eps = set(next(iter(required_panels))) eps -= {"Theater to Tunnels"} @@ -167,7 +179,8 @@ def read_logic_file(self, lines: List[str]) -> None: "entity_hex": entity_hex, "region": current_region, "id": int(location_id), - "entityType": location_type, + "entityType": entity_type, + "locationType": location_type, "area": current_area, } diff --git a/worlds/witness/data/utils.py b/worlds/witness/data/utils.py index f89aaf7d3e18..11f905b18a56 100644 --- a/worlds/witness/data/utils.py +++ b/worlds/witness/data/utils.py @@ -203,6 +203,10 @@ def get_elevators_come_to_you() -> List[str]: return get_adjustment_file("settings/Door_Shuffle/Elevators_Come_To_You.txt") +def get_entity_hunt() -> List[str]: + return get_adjustment_file("settings/Entity_Hunt.txt") + + def get_sigma_normal_logic() -> List[str]: return get_adjustment_file("WitnessLogic.txt") diff --git a/worlds/witness/entity_hunt.py b/worlds/witness/entity_hunt.py new file mode 100644 index 000000000000..29b914799fdb --- /dev/null +++ b/worlds/witness/entity_hunt.py @@ -0,0 +1,234 @@ +from collections import defaultdict +from logging import debug +from pprint import pformat +from typing import TYPE_CHECKING, Dict, List, Set, Tuple + +from .data import static_logic as static_witness_logic + +if TYPE_CHECKING: + from . import WitnessWorld + from .player_logic import WitnessPlayerLogic + +DISALLOWED_ENTITIES_FOR_PANEL_HUNT = { + "0x03629", # Tutorial Gate Open, which is the panel that is locked by panel hunt + "0x03505", # Tutorial Gate Close (same thing) + "0x3352F", # Gate EP (same thing) + "0x09F7F", # Mountaintop Box Short. This is reserved for panel_hunt_postgame. + "0x00CDB", # Challenge Reallocating + "0x0051F", # Challenge Reallocating + "0x00524", # Challenge Reallocating + "0x00CD4", # Challenge Reallocating + "0x00CB9", # Challenge May Be Unsolvable + "0x00CA1", # Challenge May Be Unsolvable + "0x00C80", # Challenge May Be Unsolvable + "0x00C68", # Challenge May Be Unsolvable + "0x00C59", # Challenge May Be Unsolvable + "0x00C22", # Challenge May Be Unsolvable + "0x0A3A8", # Reset PP + "0x0A3B9", # Reset PP + "0x0A3BB", # Reset PP + "0x0A3AD", # Reset PP +} + +ALL_HUNTABLE_PANELS = [ + entity_hex + for entity_hex, entity_obj in static_witness_logic.ENTITIES_BY_HEX.items() + if entity_obj["entityType"] == "Panel" and entity_hex not in DISALLOWED_ENTITIES_FOR_PANEL_HUNT +] + + +class EntityHuntPicker: + def __init__(self, player_logic: "WitnessPlayerLogic", world: "WitnessWorld", + pre_picked_entities: Set[str]) -> None: + self.player_logic = player_logic + self.player_options = world.options + self.player_name = world.player_name + self.random = world.random + + self.PRE_PICKED_HUNT_ENTITIES = pre_picked_entities.copy() + self.HUNT_ENTITIES: Set[str] = set() + + self.ALL_ELIGIBLE_ENTITIES, self.ELIGIBLE_ENTITIES_PER_AREA = self._get_eligible_panels() + + def pick_panel_hunt_panels(self, total_amount: int) -> Set[str]: + """ + The process of picking all hunt entities is: + + 1. Add pre-defined hunt entities + 2. Pick random hunt entities to fill out the rest + 3. Replace unfair entities with fair entities + + Each of these is its own function. + """ + + self.HUNT_ENTITIES = self.PRE_PICKED_HUNT_ENTITIES.copy() + + self._pick_all_hunt_entities(total_amount) + self._replace_unfair_hunt_entities_with_good_hunt_entities() + self._log_results() + + return self.HUNT_ENTITIES + + def _entity_is_eligible(self, panel_hex: str) -> bool: + """ + Determine whether an entity is eligible for entity hunt based on player options. + """ + panel_obj = static_witness_logic.ENTITIES_BY_HEX[panel_hex] + + return ( + self.player_logic.solvability_guaranteed(panel_hex) + and not ( + # Due to an edge case, Discards have to be on in disable_non_randomized even if Discard Shuffle is off. + # However, I don't think they should be hunt panels in this case. + self.player_options.disable_non_randomized_puzzles + and not self.player_options.shuffle_discarded_panels + and panel_obj["locationType"] == "Discard" + ) + ) + + def _get_eligible_panels(self) -> Tuple[List[str], Dict[str, Set[str]]]: + """ + There are some entities that are not allowed for panel hunt for various technical of gameplay reasons. + Make a list of all the ones that *are* eligible, plus a lookup of eligible panels per area. + """ + + all_eligible_panels = [ + panel for panel in ALL_HUNTABLE_PANELS + if self._entity_is_eligible(panel) + ] + + eligible_panels_by_area = defaultdict(set) + for eligible_panel in all_eligible_panels: + associated_area = static_witness_logic.ENTITIES_BY_HEX[eligible_panel]["area"]["name"] + eligible_panels_by_area[associated_area].add(eligible_panel) + + return all_eligible_panels, eligible_panels_by_area + + def _get_percentage_of_hunt_entities_by_area(self) -> Dict[str, float]: + hunt_entities_picked_so_far_prevent_div_0 = max(len(self.HUNT_ENTITIES), 1) + + contributing_percentage_per_area = {} + for area, eligible_entities in self.ELIGIBLE_ENTITIES_PER_AREA.items(): + amount_of_already_chosen_entities = len(self.ELIGIBLE_ENTITIES_PER_AREA[area] & self.HUNT_ENTITIES) + current_percentage = amount_of_already_chosen_entities / hunt_entities_picked_so_far_prevent_div_0 + contributing_percentage_per_area[area] = current_percentage + + return contributing_percentage_per_area + + def _get_next_random_batch(self, amount: int, same_area_discouragement: float) -> List[str]: + """ + Pick the next batch of hunt entities. + Areas that already have a lot of hunt entities in them will be discouraged from getting more. + The strength of this effect is controlled by the same_area_discouragement factor from the player's options. + """ + + percentage_of_hunt_entities_by_area = self._get_percentage_of_hunt_entities_by_area() + + max_percentage = max(percentage_of_hunt_entities_by_area.values()) + if max_percentage == 0: + allowance_per_area = {area: 1.0 for area in percentage_of_hunt_entities_by_area} + else: + allowance_per_area = { + area: (max_percentage - current_percentage) / max_percentage + for area, current_percentage in percentage_of_hunt_entities_by_area.items() + } + # use same_area_discouragement as lerp factor + allowance_per_area = { + area: (1.0 - same_area_discouragement) + (weight * same_area_discouragement) + for area, weight in allowance_per_area.items() + } + + assert min(allowance_per_area.values()) >= 0, ( + f"Somehow, an area had a negative weight when picking hunt entities: {allowance_per_area}" + ) + + remaining_entities, remaining_entity_weights = [], [] + for area, eligible_entities in self.ELIGIBLE_ENTITIES_PER_AREA.items(): + for panel in eligible_entities - self.HUNT_ENTITIES: + remaining_entities.append(panel) + remaining_entity_weights.append(allowance_per_area[area]) + + # I don't think this can ever happen, but let's be safe + if sum(remaining_entity_weights) == 0: + remaining_entity_weights = [1] * len(remaining_entity_weights) + + return self.random.choices(remaining_entities, weights=remaining_entity_weights, k=amount) + + def _pick_all_hunt_entities(self, total_amount: int) -> None: + """ + The core function of the EntityHuntPicker in which all Hunt Entities are picked, + respecting the player's choices for total amount and same area discouragement. + """ + same_area_discouragement = self.player_options.panel_hunt_discourage_same_area_factor / 100 + + # If we're using random picking, just choose all the entities now and return + if not same_area_discouragement: + hunt_entities = self.random.sample( + [entity for entity in self.ALL_ELIGIBLE_ENTITIES if entity not in self.HUNT_ENTITIES], + k=total_amount - len(self.HUNT_ENTITIES), + ) + self.HUNT_ENTITIES.update(hunt_entities) + return + + # If we're discouraging entities from the same area being picked, we have to pick entities one at a time + # For higher total counts, we do them in small batches for performance + batch_size = max(1, total_amount // 20) + + while len(self.HUNT_ENTITIES) < total_amount: + actual_amount_to_pick = min(batch_size, total_amount - len(self.HUNT_ENTITIES)) + + self.HUNT_ENTITIES.update(self._get_next_random_batch(actual_amount_to_pick, same_area_discouragement)) + + def _replace_unfair_hunt_entities_with_good_hunt_entities(self) -> None: + """ + For connected entities that "solve together", make sure that the one you're guaranteed + to be able to see and interact with first is the one that is chosen, so you don't get "surprise entities". + """ + + replacements = { + "0x18488": "0x00609", # Replace Swamp Sliding Bridge Underwater -> Swamp Sliding Bridge Above Water + "0x03676": "0x03678", # Replace Quarry Upper Ramp Control -> Lower Ramp Control + "0x03675": "0x03679", # Replace Quarry Upper Lift Control -> Lower Lift Control + + "0x03702": "0x15ADD", # Jungle Vault Box -> Jungle Vault Panel + "0x03542": "0x002A6", # Mountainside Vault Box -> Mountainside Vault Panel + "0x03481": "0x033D4", # Tutorial Vault Box -> Tutorial Vault Panel + "0x0339E": "0x0CC7B", # Desert Vault Box -> Desert Vault Panel + "0x03535": "0x00AFB", # Shipwreck Vault Box -> Shipwreck Vault Panel + } + + if self.player_options.shuffle_doors < 2: + replacements.update( + { + "0x334DC": "0x334DB", # In door shuffle, the Shadows Timer Panels are disconnected + "0x17CBC": "0x2700B", # In door shuffle, the Laser Timer Panels are disconnected + } + ) + + for bad_entitiy, good_entity in replacements.items(): + # If the bad entity was picked as a hunt entity ... + if bad_entitiy not in self.HUNT_ENTITIES: + continue + + # ... and the good entity was not ... + if good_entity in self.HUNT_ENTITIES or good_entity not in self.ALL_ELIGIBLE_ENTITIES: + continue + + # ... replace the bad entity with the good entity. + self.HUNT_ENTITIES.remove(bad_entitiy) + self.HUNT_ENTITIES.add(good_entity) + + def _log_results(self) -> None: + final_percentage_by_area = self._get_percentage_of_hunt_entities_by_area() + + sorted_area_percentages_dict = dict(sorted(final_percentage_by_area.items(), key=lambda x: x[1])) + sorted_area_percentages_dict_pretty_print = { + area: str(percentage) + (" (maxed)" if self.ELIGIBLE_ENTITIES_PER_AREA[area] <= self.HUNT_ENTITIES else "") + for area, percentage in sorted_area_percentages_dict.items() + } + player_name = self.player_name + discouragemenet_factor = self.player_options.panel_hunt_discourage_same_area_factor + debug( + f'Final area percentages for player "{player_name}" ({discouragemenet_factor} discouragement):\n' + f"{pformat(sorted_area_percentages_dict_pretty_print)}" + ) diff --git a/worlds/witness/generate_data_file.py b/worlds/witness/generate_data_file.py new file mode 100644 index 000000000000..50a63a374619 --- /dev/null +++ b/worlds/witness/generate_data_file.py @@ -0,0 +1,45 @@ +from collections import defaultdict + +from data import static_logic as static_witness_logic + +if __name__ == "__main__": + with open("data/APWitnessData.h", "w") as datafile: + datafile.write("""# pragma once + +# include +# include +# include + +""") + + area_to_location_ids = defaultdict(list) + area_to_entity_ids = defaultdict(list) + + for entity_id, entity_object in static_witness_logic.ENTITIES_BY_HEX.items(): + location_id = entity_object["id"] + + area = entity_object["area"]["name"] + area_to_entity_ids[area].append(entity_id) + + if location_id is None: + continue + + area_to_location_ids[area].append(str(location_id)) + + datafile.write("inline std::map> areaNameToLocationIDs = {\n") + datafile.write( + "\n".join( + '\t{"' + area + '", { ' + ", ".join(location_ids) + " }}," + for area, location_ids in area_to_location_ids.items() + ) + ) + datafile.write("\n};\n\n") + + datafile.write("inline std::map> areaNameToEntityIDs = {\n") + datafile.write( + "\n".join( + '\t{"' + area + '", { ' + ", ".join(entity_ids) + " }}," + for area, entity_ids in area_to_entity_ids.items() + ) + ) + datafile.write("\n};\n\n") diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index a1ca1b081d3c..248c567b97ce 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -11,7 +11,8 @@ if TYPE_CHECKING: from . import WitnessWorld -CompactItemData = Tuple[str, Union[str, int], int] +CompactHintArgs = Tuple[Union[str, int], int] +CompactHintData = Tuple[str, Union[str, int], int] @dataclass @@ -35,6 +36,7 @@ class WitnessWordedHint: location: Optional[Location] = None area: Optional[str] = None area_amount: Optional[int] = None + area_hunt_panels: Optional[int] = None def get_always_hint_items(world: "WitnessWorld") -> List[str]: @@ -391,22 +393,22 @@ def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]] return locations_per_area, items_per_area -def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: List[Item]) -> Tuple[str, int]: +def word_area_hint(world: "WitnessWorld", hinted_area: str, area_items: List[Item]) -> Tuple[str, int, Optional[int]]: """ Word the hint for an area using natural sounding language. This takes into account how much progression there is, how much of it is local/non-local, and whether there are any local lasers to be found in this area. """ - local_progression = sum(item.player == world.player and item.advancement for item in corresponding_items) - non_local_progression = sum(item.player != world.player and item.advancement for item in corresponding_items) + local_progression = sum(item.player == world.player and item.advancement for item in area_items) + non_local_progression = sum(item.player != world.player and item.advancement for item in area_items) laser_names = {"Symmetry Laser", "Desert Laser", "Quarry Laser", "Shadows Laser", "Town Laser", "Monastery Laser", "Jungle Laser", "Bunker Laser", "Swamp Laser", "Treehouse Laser", "Keep Laser", } local_lasers = sum( item.player == world.player and item.name in laser_names - for item in corresponding_items + for item in area_items ) total_progression = non_local_progression + local_progression @@ -415,11 +417,29 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: area_progression_word = "Both" if total_progression == 2 else "All" + hint_string = f"In the {hinted_area} area, you will find " + + hunt_panels = None + if world.options.victory_condition == "panel_hunt": + hunt_panels = sum( + static_witness_logic.ENTITIES_BY_HEX[hunt_entity]["area"]["name"] == hinted_area + for hunt_entity in world.player_logic.HUNT_ENTITIES + ) + + if not hunt_panels: + hint_string += "no Hunt Panels and " + + elif hunt_panels == 1: + hint_string += "1 Hunt Panel and " + + else: + hint_string += f"{hunt_panels} Hunt Panels and " + if not total_progression: - hint_string = f"In the {hinted_area} area, you will find no progression items." + hint_string += "no progression items." elif total_progression == 1: - hint_string = f"In the {hinted_area} area, you will find 1 progression item." + hint_string += "1 progression item." if player_count > 1: if local_lasers: @@ -434,7 +454,7 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: hint_string += "\nThis item is a laser." else: - hint_string = f"In the {hinted_area} area, you will find {total_progression} progression items." + hint_string += f"{total_progression} progression items." if local_lasers == total_progression: sentence_end = (" for this world." if player_count > 1 else ".") @@ -471,7 +491,7 @@ def word_area_hint(world: "WitnessWorld", hinted_area: str, corresponding_items: elif local_lasers: hint_string += f"\n{local_lasers} of them are lasers." - return hint_string, total_progression + return hint_string, total_progression, hunt_panels def make_area_hints(world: "WitnessWorld", amount: int, already_hinted_locations: Set[Location] @@ -483,9 +503,9 @@ def make_area_hints(world: "WitnessWorld", amount: int, already_hinted_locations hints = [] for hinted_area in hinted_areas: - hint_string, prog_amount = word_area_hint(world, hinted_area, items_per_area[hinted_area]) + hint_string, prog_amount, hunt_panels = word_area_hint(world, hinted_area, items_per_area[hinted_area]) - hints.append(WitnessWordedHint(hint_string, None, f"hinted_area:{hinted_area}", prog_amount)) + hints.append(WitnessWordedHint(hint_string, None, f"hinted_area:{hinted_area}", prog_amount, hunt_panels)) if len(hinted_areas) < amount: player_name = world.multiworld.get_player_name(world.player) @@ -585,29 +605,42 @@ def create_all_hints(world: "WitnessWorld", hint_amount: int, area_hints: int, return generated_hints -def make_compact_hint_data(hint: WitnessWordedHint, local_player_number: int) -> CompactItemData: +def get_compact_hint_args(hint: WitnessWordedHint, local_player_number: int) -> CompactHintArgs: + """ + Arg reference: + + Area Hint: 1st Arg is the amount of area progression and hunt panels. 2nd Arg is the name of the area. + Location Hint: 1st Arg is the location's address, second arg is the player number the location belongs to. + Junk Hint: 1st Arg is -1, second arg is this slot's player number. + """ + + # Is Area Hint + if hint.area is not None: + assert hint.area_amount is not None, "Area hint had an undefined progression amount." + + area_amount = hint.area_amount + hunt_panels = hint.area_hunt_panels + + area_and_hunt_panels = area_amount + # Encode amounts together + if hunt_panels: + area_and_hunt_panels += 0x100 * hunt_panels + + return hint.area, area_and_hunt_panels + location = hint.location - area_amount = hint.area_amount - # -1 if junk hint, address if location hint, area string if area hint - arg_1: Union[str, int] + # Is location hint if location and location.address is not None: - arg_1 = location.address - elif hint.area is not None: - arg_1 = hint.area - else: - arg_1 = -1 - - # self.player if junk hint, player if location hint, progression amount if area hint - arg_2: int - if area_amount is not None: - arg_2 = area_amount - elif location is not None: - arg_2 = location.player - else: - arg_2 = local_player_number + return location.address, location.player + + # Is junk / undefined hint + return -1, local_player_number + - return hint.wording, arg_1, arg_2 +def make_compact_hint_data(hint: WitnessWordedHint, local_player_number: int) -> CompactHintData: + compact_arg_1, compact_arg_2 = get_compact_hint_args(hint, local_player_number) + return hint.wording, compact_arg_1, compact_arg_2 def make_laser_hints(world: "WitnessWorld", laser_names: List[str]) -> Dict[str, WitnessWordedHint]: diff --git a/worlds/witness/locations.py b/worlds/witness/locations.py index 1796f051b896..f1c16550399a 100644 --- a/worlds/witness/locations.py +++ b/worlds/witness/locations.py @@ -50,7 +50,7 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic) -> N self.CHECK_PANELHEX_TO_ID = { static_witness_logic.ENTITIES_BY_NAME[ch]["entity_hex"]: static_witness_locations.ALL_LOCATIONS_TO_ID[ch] for ch in self.CHECK_LOCATIONS - if static_witness_logic.ENTITIES_BY_NAME[ch]["entityType"] in self.PANEL_TYPES_TO_SHUFFLE + if static_witness_logic.ENTITIES_BY_NAME[ch]["locationType"] in self.PANEL_TYPES_TO_SHUFFLE } dog_hex = static_witness_logic.ENTITIES_BY_NAME["Town Pet the Dog"]["entity_hex"] @@ -61,11 +61,9 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic) -> N sorted(self.CHECK_PANELHEX_TO_ID.items(), key=lambda item: item[1]) ) - event_locations = set(player_logic.USED_EVENT_NAMES_BY_HEX) - self.EVENT_LOCATION_TABLE = { - static_witness_locations.get_event_name(entity_hex): None - for entity_hex in event_locations + event_location: None + for event_location in player_logic.EVENT_ITEM_PAIRS } check_dict = { diff --git a/worlds/witness/options.py b/worlds/witness/options.py index 4855fc715933..bdeccfe3b2db 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -173,6 +173,7 @@ class VictoryCondition(Choice): - Challenge: Beat the secret Challenge (requires Challenge Lasers). - Mountain Box Short: Input the short solution to the Mountaintop Box (requires Mountain Lasers). - Mountain Box Long: Input the long solution to the Mountaintop Box (requires Challenge Lasers). + - Panel Hunt: Solve a specific number of randomly selected panels before going to the secret ending in Tutorial. It is important to note that while the Mountain Box requires Desert Laser to be redirected in Town for that laser to count, the laser locks on the Elevator and Challenge Timer panels do not. @@ -182,6 +183,62 @@ class VictoryCondition(Choice): option_challenge = 1 option_mountain_box_short = 2 option_mountain_box_long = 3 + option_panel_hunt = 4 + + +class PanelHuntTotal(Range): + """ + Sets the number of random panels that will get marked as "Panel Hunt" panels in the "Panel Hunt" game mode. + """ + display_name = "Total Panel Hunt panels" + range_start = 5 + range_end = 100 + default = 40 + + +class PanelHuntRequiredPercentage(Range): + """ + Determines the percentage of "Panel Hunt" panels that need to be solved to win. + """ + display_name = "Percentage of required Panel Hunt panels" + range_start = 20 + range_end = 100 + default = 63 + + +class PanelHuntPostgame(Choice): + """ + In panel hunt, there are technically no postgame locations. + Depending on your options, this can leave Mountain and Caves as two huge areas with Hunt Panels in them that cannot be reached until you get enough lasers to go through the very linear Mountain descent. + Panel Hunt tends to be more fun when the world is open. + This option lets you force anything locked by lasers to be disabled, and thus ineligible for Hunt Panels. + To compensate, the respective mountain box solution (short box / long box) will be forced to be a Hunt Panel. + Does nothing if Panel Hunt is not your victory condition. + + Note: The "Mountain Lasers" option may also affect locations locked by challenge lasers if the only path to those locations leads through the Mountain Entry. + """ + + display_name = "Force postgame in Panel Hunt" + + option_everything_is_eligible = 0 + option_disable_mountain_lasers_locations = 1 + option_disable_challenge_lasers_locations = 2 + option_disable_anything_locked_by_lasers = 3 + default = 3 + + +class PanelHuntDiscourageSameAreaFactor(Range): + """ + The greater this value, the less likely it is that many Hunt Panels show up in the same area. + + At 0, Hunt Panels will be selected randomly. + At 100, Hunt Panels will be almost completely evenly distributed between areas. + """ + display_name = "Panel Hunt Discourage Same Area Factor" + + range_start = 0 + range_end = 100 + default = 40 class PuzzleRandomization(Choice): @@ -332,6 +389,10 @@ class TheWitnessOptions(PerGameCommonOptions): victory_condition: VictoryCondition mountain_lasers: MountainLasers challenge_lasers: ChallengeLasers + panel_hunt_total: PanelHuntTotal + panel_hunt_required_percentage: PanelHuntRequiredPercentage + panel_hunt_postgame: PanelHuntPostgame + panel_hunt_discourage_same_area_factor: PanelHuntDiscourageSameAreaFactor early_caves: EarlyCaves early_symbol_item: EarlySymbolItem elevators_come_to_you: ElevatorsComeToYou @@ -352,6 +413,12 @@ class TheWitnessOptions(PerGameCommonOptions): MountainLasers, ChallengeLasers, ]), + OptionGroup("Panel Hunt Settings", [ + PanelHuntRequiredPercentage, + PanelHuntTotal, + PanelHuntPostgame, + PanelHuntDiscourageSameAreaFactor, + ], start_collapsed=True), OptionGroup("Locations", [ ShuffleDiscardedPanels, ShuffleVaultBoxes, diff --git a/worlds/witness/player_items.py b/worlds/witness/player_items.py index 718fd7d172ba..44a959f2b428 100644 --- a/worlds/witness/player_items.py +++ b/worlds/witness/player_items.py @@ -97,7 +97,7 @@ def __init__(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic, # Add event items to the item definition list for later lookup. for event_location in self._locations.EVENT_LOCATION_TABLE: - location_name = player_logic.EVENT_ITEM_PAIRS[event_location] + location_name = player_logic.EVENT_ITEM_PAIRS[event_location][0] self.item_data[location_name] = ItemData(None, ItemDefinition(0, ItemCategory.EVENT), ItemClassification.progression, False) diff --git a/worlds/witness/player_logic.py b/worlds/witness/player_logic.py index e8d11f43f51c..5125dfef0aa1 100644 --- a/worlds/witness/player_logic.py +++ b/worlds/witness/player_logic.py @@ -17,7 +17,6 @@ import copy from collections import defaultdict -from logging import warning from typing import TYPE_CHECKING, Dict, List, Set, Tuple, cast from .data import static_logic as static_witness_logic @@ -36,6 +35,7 @@ get_early_caves_list, get_early_caves_start_list, get_elevators_come_to_you, + get_entity_hunt, get_ep_all_individual, get_ep_easy, get_ep_no_eclipse, @@ -51,6 +51,7 @@ logical_or_witness_rules, parse_lambda, ) +from .entity_hunt import EntityHuntPicker if TYPE_CHECKING: from . import WitnessWorld @@ -60,7 +61,7 @@ class WitnessPlayerLogic: """WITNESS LOGIC CLASS""" VICTORY_LOCATION: str - + def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_inv: Dict[str, int]) -> None: self.YAML_DISABLED_LOCATIONS: Set[str] = disabled_locations self.YAML_ADDED_ITEMS: Dict[str, int] = start_inv @@ -104,7 +105,7 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in ) self.REQUIREMENTS_BY_HEX: Dict[str, WitnessRule] = {} - self.EVENT_ITEM_PAIRS: Dict[str, str] = {} + self.EVENT_ITEM_PAIRS: Dict[str, Tuple[str, str]] = {} self.COMPLETELY_DISABLED_ENTITIES: Set[str] = set() self.DISABLE_EVERYTHING_BEHIND: Set[str] = set() self.PRECOMPLETED_LOCATIONS: Set[str] = set() @@ -112,6 +113,9 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in self.ADDED_CHECKS: Set[str] = set() self.VICTORY_LOCATION = "0x0356B" + self.PRE_PICKED_HUNT_ENTITIES: Set[str] = set() + self.HUNT_ENTITIES: Set[str] = set() + self.ALWAYS_EVENT_NAMES_BY_HEX = { "0x00509": "+1 Laser (Symmetry Laser)", "0x012FB": "+1 Laser (Desert Laser)", @@ -129,7 +133,7 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in "0xFFF00": "Bottom Floor Discard Turns On", } - self.USED_EVENT_NAMES_BY_HEX: Dict[str, str] = {} + self.USED_EVENT_NAMES_BY_HEX: Dict[str, List[str]] = {} self.CONDITIONAL_EVENTS: Dict[Tuple[str, str], str] = {} # The basic requirements to solve each entity come from StaticWitnessLogic. @@ -142,6 +146,10 @@ def __init__(self, world: "WitnessWorld", disabled_locations: Set[str], start_in # This will make the access conditions way faster, instead of recursively checking dependent entities each time. self.make_dependency_reduced_checklist() + if world.options.victory_condition == "panel_hunt": + picker = EntityHuntPicker(self, world, self.PRE_PICKED_HUNT_ENTITIES) + self.HUNT_ENTITIES = picker.pick_panel_hunt_panels(world.options.panel_hunt_total.value) + # Finalize which items actually exist in the MultiWorld and which get grouped into progressive items. self.finalize_items() @@ -226,7 +234,7 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: dep_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX.get(option_entity, {}) if option_entity in {"7 Lasers", "11 Lasers", "7 Lasers + Redirect", "11 Lasers + Redirect", - "PP2 Weirdness", "Theater to Tunnels"}: + "PP2 Weirdness", "Theater to Tunnels", "Entity Hunt"}: new_items = frozenset({frozenset([option_entity])}) elif option_entity in self.DISABLE_EVERYTHING_BEHIND: new_items = frozenset() @@ -241,12 +249,12 @@ def reduce_req_within_region(self, entity_hex: str) -> WitnessRule: # If the dependent entity is unsolvable and is NOT an EP, this requirement option is invalid. new_items = frozenset() elif option_entity in self.ALWAYS_EVENT_NAMES_BY_HEX: - new_items = frozenset({frozenset([option_entity])}) + new_items = frozenset({frozenset([self.ALWAYS_EVENT_NAMES_BY_HEX[option_entity]])}) elif (entity_hex, option_entity) in self.CONDITIONAL_EVENTS: - new_items = frozenset({frozenset([option_entity])}) - self.USED_EVENT_NAMES_BY_HEX[option_entity] = self.CONDITIONAL_EVENTS[ - (entity_hex, option_entity) - ] + new_items = frozenset({frozenset([self.CONDITIONAL_EVENTS[(entity_hex, option_entity)]])}) + self.USED_EVENT_NAMES_BY_HEX[option_entity].append( + self.CONDITIONAL_EVENTS[(entity_hex, option_entity)] + ) else: new_items = theoretical_new_items if dep_obj["region"] and entity_obj["region"] != dep_obj["region"]: @@ -404,7 +412,7 @@ def make_single_adjustment(self, adj_type: str, line: str) -> None: line = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[line]["checkName"] self.ADDED_CHECKS.add(line) - def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]: + def handle_regular_postgame(self, world: "WitnessWorld") -> List[List[str]]: """ In shuffle_postgame, panels that become accessible "after or at the same time as the goal" are disabled. This mostly involves the disabling of key panels (e.g. long box when the goal is short box). @@ -435,6 +443,7 @@ def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]: # If we have a long box goal, Challenge is behind the amount of lasers required to just win. # This is technically slightly incorrect as the Challenge Vault Box could contain a *symbol* that is required # to open Mountain Entry (Stars 2). However, since there is a very easy sphere 1 snipe, this is not considered. + if victory == "mountain_box_long": postgame_adjustments.append(["Disabled Locations:", "0x0A332 (Challenge Timer Start)"]) @@ -479,6 +488,42 @@ def handle_postgame(self, world: "WitnessWorld") -> List[List[str]]: return postgame_adjustments + def handle_panelhunt_postgame(self, world: "WitnessWorld") -> List[List[str]]: + postgame_adjustments = [] + + # Make some quick references to some options + panel_hunt_postgame = world.options.panel_hunt_postgame + chal_lasers = world.options.challenge_lasers + + disable_mountain_lasers = ( + panel_hunt_postgame == "disable_mountain_lasers_locations" + or panel_hunt_postgame == "disable_anything_locked_by_lasers" + ) + + disable_challenge_lasers = ( + panel_hunt_postgame == "disable_challenge_lasers_locations" + or panel_hunt_postgame == "disable_anything_locked_by_lasers" + ) + + if disable_mountain_lasers: + self.DISABLE_EVERYTHING_BEHIND.add("0x09F7F") # Short box + self.PRE_PICKED_HUNT_ENTITIES.add("0x09F7F") + self.COMPLETELY_DISABLED_ENTITIES.add("0x3D9A9") # Elevator Start + + # If mountain lasers are disabled, and challenge lasers > 7, the box will need to be rotated + if chal_lasers > 7: + postgame_adjustments.append([ + "Requirement Changes:", + "0xFFF00 - 11 Lasers - True", + ]) + + if disable_challenge_lasers: + self.DISABLE_EVERYTHING_BEHIND.add("0xFFF00") # Long box + self.PRE_PICKED_HUNT_ENTITIES.add("0xFFF00") + self.COMPLETELY_DISABLED_ENTITIES.add("0x0A332") # Challenge Timer + + return postgame_adjustments + def make_options_adjustments(self, world: "WitnessWorld") -> None: """Makes logic adjustments based on options""" adjustment_linesets_in_order = [] @@ -500,10 +545,17 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: self.VICTORY_LOCATION = "0x09F7F" elif victory == "mountain_box_long": self.VICTORY_LOCATION = "0xFFF00" + elif victory == "panel_hunt": + self.VICTORY_LOCATION = "0x03629" + self.COMPLETELY_DISABLED_ENTITIES.add("0x3352F") # Exclude panels from the post-game if shuffle_postgame is false. - if not world.options.shuffle_postgame: - adjustment_linesets_in_order += self.handle_postgame(world) + if not world.options.shuffle_postgame and victory != "panel_hunt": + adjustment_linesets_in_order += self.handle_regular_postgame(world) + + # Exclude panels from the post-game if shuffle_postgame is false. + if victory == "panel_hunt" and world.options.panel_hunt_postgame: + adjustment_linesets_in_order += self.handle_panelhunt_postgame(world) # Exclude Discards / Vaults if not world.options.shuffle_discarded_panels: @@ -570,6 +622,9 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: if world.options.elevators_come_to_you: adjustment_linesets_in_order.append(get_elevators_come_to_you()) + if world.options.victory_condition == "panel_hunt": + adjustment_linesets_in_order.append(get_entity_hunt()) + for item in self.YAML_ADDED_ITEMS: adjustment_linesets_in_order.append(["Items:", item]) @@ -603,7 +658,7 @@ def make_options_adjustments(self, world: "WitnessWorld") -> None: if loc_obj["entityType"] == "EP": self.COMPLETELY_DISABLED_ENTITIES.add(loc_obj["entity_hex"]) - elif loc_obj["entityType"] in {"General", "Vault", "Discard"}: + elif loc_obj["entityType"] == "Panel": self.EXCLUDED_LOCATIONS.add(loc_obj["entity_hex"]) for adjustment_lineset in adjustment_linesets_in_order: @@ -686,6 +741,7 @@ def find_unsolvable_entities(self, world: "WitnessWorld") -> None: # Check if any regions have become unreachable. reachable_regions = self.discover_reachable_regions() new_unreachable_regions = all_regions - reachable_regions - self.UNREACHABLE_REGIONS + if new_unreachable_regions: self.UNREACHABLE_REGIONS.update(new_unreachable_regions) @@ -741,9 +797,12 @@ def reduce_connection_requirement(self, connection: Tuple[str, WitnessRule]) -> if not self.solvability_guaranteed(entity) or entity in self.DISABLE_EVERYTHING_BEHIND: individual_entity_requirements.append(frozenset()) # If a connection requires acquiring an event, add that event to its requirements. - elif (entity in self.ALWAYS_EVENT_NAMES_BY_HEX - or entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX): + elif entity not in self.REFERENCE_LOGIC.ENTITIES_BY_HEX: individual_entity_requirements.append(frozenset({frozenset({entity})})) + elif entity in self.ALWAYS_EVENT_NAMES_BY_HEX: + individual_entity_requirements.append( + frozenset({frozenset({self.ALWAYS_EVENT_NAMES_BY_HEX[entity]})}) + ) # If a connection requires entities, use their newly calculated independent requirements. else: entity_req = self.get_entity_requirement(entity) @@ -778,7 +837,7 @@ def make_dependency_reduced_checklist(self) -> None: # We also clear any data structures that we might have filled in a previous dependency reduction self.REQUIREMENTS_BY_HEX = {} - self.USED_EVENT_NAMES_BY_HEX = {} + self.USED_EVENT_NAMES_BY_HEX = defaultdict(list) self.CONNECTIONS_BY_REGION_NAME = {} self.PROG_ITEMS_ACTUALLY_IN_THE_GAME_NO_MULTI = set() @@ -868,7 +927,6 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: "0x17CC4": come_to_you or eps_shuffled, # Quarry Elevator Panel "0x17E2B": come_to_you and boat_shuffled or eps_shuffled, # Swamp Long Bridge "0x0CF2A": False, # Jungle Monastery Garden Shortcut - "0x17CAA": remote_doors, # Jungle Monastery Garden Shortcut Panel "0x0364E": False, # Monastery Laser Shortcut Door "0x03713": remote_doors, # Monastery Laser Shortcut Panel "0x03313": False, # Orchard Second Gate @@ -884,23 +942,17 @@ def determine_unrequired_entities(self, world: "WitnessWorld") -> None: # Jungle Popup Wall Panel } + # In panel hunt, all panels are game, so all panels need to be reachable (unless disabled) + if goal == "panel_hunt": + for entity_hex in is_item_required_dict: + if static_witness_logic.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Panel": + is_item_required_dict[entity_hex] = True + # Now, return the keys of the dict entries where the result is False to get unrequired major items self.ENTITIES_WITHOUT_ENSURED_SOLVABILITY |= { item_name for item_name, is_required in is_item_required_dict.items() if not is_required } - def make_event_item_pair(self, entity_hex: str) -> Tuple[str, str]: - """ - Makes a pair of an event panel and its event item - """ - action = " Opened" if self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["entityType"] == "Door" else " Solved" - - name = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex]["checkName"] + action - if entity_hex not in self.USED_EVENT_NAMES_BY_HEX: - warning(f'Entity "{name}" does not have an associated event name.') - self.USED_EVENT_NAMES_BY_HEX[entity_hex] = name + " Event" - return (name, self.USED_EVENT_NAMES_BY_HEX[entity_hex]) - def make_event_panel_lists(self) -> None: """ Makes event-item pairs for entities with associated events, unless these entities are disabled. @@ -908,13 +960,36 @@ def make_event_panel_lists(self) -> None: self.ALWAYS_EVENT_NAMES_BY_HEX[self.VICTORY_LOCATION] = "Victory" - self.USED_EVENT_NAMES_BY_HEX.update(self.ALWAYS_EVENT_NAMES_BY_HEX) + for event_hex, event_name in self.ALWAYS_EVENT_NAMES_BY_HEX.items(): + self.USED_EVENT_NAMES_BY_HEX[event_hex].append(event_name) self.USED_EVENT_NAMES_BY_HEX = { - event_hex: event_name for event_hex, event_name in self.USED_EVENT_NAMES_BY_HEX.items() + event_hex: event_list for event_hex, event_list in self.USED_EVENT_NAMES_BY_HEX.items() if self.solvability_guaranteed(event_hex) } - for panel in self.USED_EVENT_NAMES_BY_HEX: - pair = self.make_event_item_pair(panel) - self.EVENT_ITEM_PAIRS[pair[0]] = pair[1] + for entity_hex, event_names in self.USED_EVENT_NAMES_BY_HEX.items(): + entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex] + entity_name = entity_obj["checkName"] + entity_type = entity_obj["entityType"] + + if entity_type == "Door": + action = " Opened" + elif entity_type == "Laser": + action = " Activated" + else: + action = " Solved" + + for i, event_name in enumerate(event_names): + if i == 0: + self.EVENT_ITEM_PAIRS[entity_name + action] = (event_name, entity_hex) + else: + self.EVENT_ITEM_PAIRS[entity_name + action + f" (Effect {i + 1})"] = (event_name, entity_hex) + + # Make Panel Hunt Events + for entity_hex in self.HUNT_ENTITIES: + entity_obj = self.REFERENCE_LOGIC.ENTITIES_BY_HEX[entity_hex] + entity_name = entity_obj["checkName"] + self.EVENT_ITEM_PAIRS[entity_name + " (Panel Hunt)"] = ("+1 Panel Hunt", entity_hex) + + return diff --git a/worlds/witness/regions.py b/worlds/witness/regions.py index 2528c8abe22b..6d1f8093af85 100644 --- a/worlds/witness/regions.py +++ b/worlds/witness/regions.py @@ -9,7 +9,6 @@ from worlds.generic.Rules import CollectionRule -from .data import static_locations as static_witness_locations from .data import static_logic as static_witness_logic from .data.static_logic import StaticWitnessLogicObj from .data.utils import WitnessRule, optimize_witness_rule @@ -111,16 +110,24 @@ def create_regions(self, world: "WitnessWorld", player_logic: WitnessPlayerLogic if k not in player_logic.UNREACHABLE_REGIONS } + event_locations_per_region = defaultdict(list) + + for event_location, event_item_and_entity in player_logic.EVENT_ITEM_PAIRS.items(): + region = static_witness_logic.ENTITIES_BY_HEX[event_item_and_entity[1]]["region"] + if region is None: + region_name = "Entry" + else: + region_name = region["name"] + event_locations_per_region[region_name].append(event_location) + for region_name, region in regions_to_create.items(): locations_for_this_region = [ self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] for panel in region["entities"] if self.reference_logic.ENTITIES_BY_HEX[panel]["checkName"] in self.player_locations.CHECK_LOCATION_TABLE ] - locations_for_this_region += [ - static_witness_locations.get_event_name(panel) for panel in region["entities"] - if static_witness_locations.get_event_name(panel) in self.player_locations.EVENT_LOCATION_TABLE - ] + + locations_for_this_region += event_locations_per_region[region_name] all_locations = all_locations | set(locations_for_this_region) diff --git a/worlds/witness/rules.py b/worlds/witness/rules.py index fc4e638e36c3..eecea8f30bf0 100644 --- a/worlds/witness/rules.py +++ b/worlds/witness/rules.py @@ -10,7 +10,6 @@ from .data import static_logic as static_witness_logic from .data.utils import WitnessRule -from .locations import WitnessPlayerLocations from .player_logic import WitnessPlayerLogic if TYPE_CHECKING: @@ -31,42 +30,37 @@ ] -def _has_laser(laser_hex: str, world: "WitnessWorld", player: int, redirect_required: bool) -> CollectionRule: +def _can_do_panel_hunt(world: "WitnessWorld") -> CollectionRule: + required = world.panel_hunt_required_count + player = world.player + return lambda state: state.has("+1 Panel Hunt", player, required) + + +def _has_laser(laser_hex: str, world: "WitnessWorld", redirect_required: bool) -> CollectionRule: + player = world.player + laser_name = static_witness_logic.ENTITIES_BY_HEX[laser_hex]["checkName"] + + # Workaround for intentional naming inconsistency + if laser_name == "Symmetry Island Laser": + laser_name = "Symmetry Laser" + if laser_hex == "0x012FB" and redirect_required: - return lambda state: ( - _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations)(state) - and state.has("Desert Laser Redirection", player) - ) + return lambda state: state.has_all([f"+1 Laser ({laser_name})", "Desert Laser Redirection"], player) - return _can_solve_panel(laser_hex, world, world.player, world.player_logic, world.player_locations) + return lambda state: state.has(f"+1 Laser ({laser_name})", player) def _has_lasers(amount: int, world: "WitnessWorld", redirect_required: bool) -> CollectionRule: laser_lambdas = [] for laser_hex in laser_hexes: - has_laser_lambda = _has_laser(laser_hex, world, world.player, redirect_required) + has_laser_lambda = _has_laser(laser_hex, world, redirect_required) laser_lambdas.append(has_laser_lambda) return lambda state: sum(laser_lambda(state) for laser_lambda in laser_lambdas) >= amount -def _can_solve_panel(panel: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic, - player_locations: WitnessPlayerLocations) -> CollectionRule: - """ - Determines whether a panel can be solved - """ - - panel_obj = player_logic.REFERENCE_LOGIC.ENTITIES_BY_HEX[panel] - entity_name = panel_obj["checkName"] - - if entity_name + " Solved" in player_locations.EVENT_LOCATION_TABLE: - return lambda state: state.has(player_logic.EVENT_ITEM_PAIRS[entity_name + " Solved"], player) - - return make_lambda(panel, world) - - def _can_do_expert_pp2(state: CollectionState, world: "WitnessWorld") -> bool: """ For Expert PP2, you need a way to access PP2 from the front, and a separate way from the back. @@ -202,8 +196,9 @@ def _can_do_theater_to_tunnels(state: CollectionState, world: "WitnessWorld") -> ) -def _has_item(item: str, world: "WitnessWorld", player: int, - player_logic: WitnessPlayerLogic, player_locations: WitnessPlayerLocations) -> CollectionRule: +def _has_item(item: str, world: "WitnessWorld", player: int, player_logic: WitnessPlayerLogic) -> CollectionRule: + assert item not in static_witness_logic.ENTITIES_BY_HEX, "Requirements can no longer contain entity hexes directly." + if item in player_logic.REFERENCE_LOGIC.ALL_REGIONS_BY_NAME: region = world.get_region(item) return region.can_reach @@ -219,12 +214,13 @@ def _has_item(item: str, world: "WitnessWorld", player: int, if item == "11 Lasers + Redirect": laser_req = world.options.challenge_lasers.value return _has_lasers(laser_req, world, True) + if item == "Entity Hunt": + # Right now, panel hunt is the only type of entity hunt. This may need to be changed later + return _can_do_panel_hunt(world) if item == "PP2 Weirdness": return lambda state: _can_do_expert_pp2(state, world) if item == "Theater to Tunnels": return lambda state: _can_do_theater_to_tunnels(state, world) - if item in player_logic.USED_EVENT_NAMES_BY_HEX: - return _can_solve_panel(item, world, player, player_logic, player_locations) prog_item = static_witness_logic.get_parent_progressive_item(item) return lambda state: state.has(prog_item, player, player_logic.MULTI_AMOUNTS[item]) @@ -237,7 +233,7 @@ def _meets_item_requirements(requirements: WitnessRule, world: "WitnessWorld") - """ lambda_conversion = [ - [_has_item(item, world, world.player, world.player_logic, world.player_locations) for item in subset] + [_has_item(item, world, world.player, world.player_logic) for item in subset] for subset in requirements ] @@ -265,7 +261,8 @@ def set_rules(world: "WitnessWorld") -> None: real_location = location if location in world.player_locations.EVENT_LOCATION_TABLE: - real_location = location[:-7] + entity_hex = world.player_logic.EVENT_ITEM_PAIRS[location][1] + real_location = static_witness_logic.ENTITIES_BY_HEX[entity_hex]["checkName"] associated_entity = world.player_logic.REFERENCE_LOGIC.ENTITIES_BY_NAME[real_location] entity_hex = associated_entity["entity_hex"] diff --git a/worlds/witness/test/__init__.py b/worlds/witness/test/__init__.py index 0a24467feab2..d1b90ca47d9e 100644 --- a/worlds/witness/test/__init__.py +++ b/worlds/witness/test/__init__.py @@ -159,3 +159,36 @@ def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) if isinstance(item_names, str): item_names = (item_names,) return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player] + + def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, also make sure that this (non-event) location COULD exist. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + try: + world.get_location(location_name) + except KeyError: + self.fail(f"Location {location_name} does not exist.") + + def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None: + """ + Assert that a location exists in this world. + If strict_check, be explicit about whether the location could exist in the first place. + """ + + world = self.multiworld.worlds[player] + + if strict_check: + self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist") + + self.assertRaises( + KeyError, + lambda _: world.get_location(location_name), + f"Location {location_name} exists, but is not supposed to.", + ) diff --git a/worlds/witness/test/test_panel_hunt.py b/worlds/witness/test/test_panel_hunt.py new file mode 100644 index 000000000000..7b405f29ec1d --- /dev/null +++ b/worlds/witness/test/test_panel_hunt.py @@ -0,0 +1,107 @@ +from BaseClasses import CollectionState, Item +from worlds.witness.test import WitnessTestBase, WitnessMultiworldTestBase + + +class TestMaxPanelHuntMinChecks(WitnessTestBase): + options = { + "victory_condition": "panel_hunt", + "panel_hunt_total": 100, + "panel_hunt_required_percentage": 100, + "panel_hunt_postgame": "disable_anything_locked_by_lasers", + "disable_non_randomized_puzzles": True, + "shuffle_discarded_panels": False, + "shuffle_vault_boxes": False, + } + + def test_correct_panels_were_picked(self): + with self.subTest("Check that 100 Hunt Panels were actually picked."): + self.assertEqual(len(self.multiworld.find_item_locations("+1 Panel Hunt", self.player)), 100) + + with self.subTest("Check that 100 Hunt Panels are enough"): + state_100 = CollectionState(self.multiworld) + panel_hunt_item = self.get_item_by_name("+1 Panel Hunt") + + for _ in range(100): + state_100.collect(panel_hunt_item, True) + state_100.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")]) + + self.assertTrue(self.multiworld.completion_condition[self.player](state_100)) + + with self.subTest("Check that 99 Hunt Panels are not enough"): + state_99 = CollectionState(self.multiworld) + panel_hunt_item = self.get_item_by_name("+1 Panel Hunt") + + for _ in range(99): + state_99.collect(panel_hunt_item, True) + state_99.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")]) + + self.assertFalse(self.multiworld.completion_condition[self.player](state_99)) + + +class TestPanelHuntPostgame(WitnessMultiworldTestBase): + options_per_world = [ + { + "panel_hunt_postgame": "everything_is_eligible" + }, + { + "panel_hunt_postgame": "disable_mountain_lasers_locations" + }, + { + "panel_hunt_postgame": "disable_challenge_lasers_locations" + }, + { + "panel_hunt_postgame": "disable_anything_locked_by_lasers" + }, + ] + + common_options = { + "victory_condition": "panel_hunt", + "panel_hunt_total": 40, + + # Make sure we can check for Short vs Long Lasers locations by making Mountain Bottom Floor Discard accessible. + "shuffle_doors": "doors", + "shuffle_discarded_panels": True, + } + + def test_panel_hunt_postgame(self): + for player_minus_one, options in enumerate(self.options_per_world): + player = player_minus_one + 1 + postgame_option = options["panel_hunt_postgame"] + with self.subTest(f"Test that \"{postgame_option}\" results in 40 Hunt Panels."): + self.assertEqual(len(self.multiworld.find_item_locations("+1 Panel Hunt", player)), 40) + + # Test that the box gets extra checks from panel_hunt_postgame + + with self.subTest("Test that \"everything_is_eligible\" has no Mountaintop Box Hunt Panels."): + self.assert_location_does_not_exist("Mountaintop Box Short (Panel Hunt)", 1, strict_check=False) + self.assert_location_does_not_exist("Mountaintop Box Long (Panel Hunt)", 1, strict_check=False) + + with self.subTest("Test that \"disable_mountain_lasers_locations\" has a Hunt Panel for Short, but not Long."): + self.assert_location_exists("Mountaintop Box Short (Panel Hunt)", 2, strict_check=False) + self.assert_location_does_not_exist("Mountaintop Box Long (Panel Hunt)", 2, strict_check=False) + + with self.subTest("Test that \"disable_challenge_lasers_locations\" has a Hunt Panel for Long, but not Short."): + self.assert_location_does_not_exist("Mountaintop Box Short (Panel Hunt)", 3, strict_check=False) + self.assert_location_exists("Mountaintop Box Long (Panel Hunt)", 3, strict_check=False) + + with self.subTest("Test that \"disable_anything_locked_by_lasers\" has both Mountaintop Box Hunt Panels."): + self.assert_location_exists("Mountaintop Box Short (Panel Hunt)", 4, strict_check=False) + self.assert_location_exists("Mountaintop Box Long (Panel Hunt)", 4, strict_check=False) + + # Check panel_hunt_postgame locations get disabled + + with self.subTest("Test that \"everything_is_eligible\" does not disable any locked-by-lasers panels."): + self.assert_location_exists("Mountain Floor 1 Right Row 5", 1) + self.assert_location_exists("Mountain Bottom Floor Discard", 1) + + with self.subTest("Test that \"disable_mountain_lasers_locations\" disables only Shortbox-Locked panels."): + self.assert_location_does_not_exist("Mountain Floor 1 Right Row 5", 2) + self.assert_location_exists("Mountain Bottom Floor Discard", 2) + + with self.subTest("Test that \"disable_challenge_lasers_locations\" disables only Longbox-Locked panels."): + self.assert_location_exists("Mountain Floor 1 Right Row 5", 3) + self.assert_location_does_not_exist("Mountain Bottom Floor Discard", 3) + + with self.subTest("Test that \"everything_is_eligible\" disables only Shortbox-Locked panels."): + self.assert_location_does_not_exist("Mountain Floor 1 Right Row 5", 4) + self.assert_location_does_not_exist("Mountain Bottom Floor Discard", 4) From c4e7b6ca822da05241330174c852c12c96a210e7 Mon Sep 17 00:00:00 2001 From: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com> Date: Tue, 20 Aug 2024 01:34:40 +0200 Subject: [PATCH 4/5] The Witness: Add "vague" hints making use of other games' region names and location groups (#2921) * Vague hints work! But, the client will probably reveal some of the info through scouts atm * Fall back on Everywhere if necessary * Some of these failsafes are not necessary now * Limit region size to 100 as well * Actually... like this. * Nutmeg * Lol * -1 for own player but don't scout * Still make always/priority ITEM hints * fix * uwu notices your bug * The hints should, like, actually work, you know? * Make it a Toggle * Update worlds/witness/hints.py Co-authored-by: Bryce Wilson * Update worlds/witness/hints.py Co-authored-by: Bryce Wilson * Make some suggested changes * Make that ungodly equation a bit clearer in terms of formatting * make that not sorted * Add a warning about the feature in the option tooltip * Make using region names experimental * reword option tooltip * Note about singleplayer * Slight rewording again * Reorder the order of priority a bit * this condition is unnecessary now * comment * No wait the order has to be like this * Okay now I think it's correct * Another comment * Align option tooltip with new behavior * slight rewording again * reword reword reword reword * - * ethics * Update worlds/witness/options.py Co-authored-by: Bryce Wilson * Rename and slight behavior change for local hints * I think I overengineered this system before. Make it more consistent and clear now * oops I used checks by accident * oops * OMEGA OOPS * Accidentally commited a print statemetn * Vi don't commit nonsense challenge difficulty impossible * This isn't always true but it's good enough * Update options.py * Update worlds/witness/options.py Co-authored-by: Scipio Wright * Scipio :3 * switch to is_event instead of checking against location.address * oop * Update test_roll_other_options.py * Fix that unit test problem lol * Oh is this not fixed in the apworld? --------- Co-authored-by: Bryce Wilson Co-authored-by: Scipio Wright --- worlds/witness/hints.py | 152 ++++++++++++++---- worlds/witness/options.py | 21 +++ worlds/witness/test/test_panel_hunt.py | 4 +- .../witness/test/test_roll_other_options.py | 1 + 4 files changed, 142 insertions(+), 36 deletions(-) diff --git a/worlds/witness/hints.py b/worlds/witness/hints.py index 248c567b97ce..c8ddf260d4e6 100644 --- a/worlds/witness/hints.py +++ b/worlds/witness/hints.py @@ -1,8 +1,9 @@ import logging +import math from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union, cast -from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld +from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from .data import static_logic as static_witness_logic from .data.utils import weighted_sample @@ -11,8 +12,8 @@ if TYPE_CHECKING: from . import WitnessWorld -CompactHintArgs = Tuple[Union[str, int], int] -CompactHintData = Tuple[str, Union[str, int], int] +CompactHintArgs = Tuple[Union[str, int], Union[str, int]] +CompactHintData = Tuple[str, Union[str, int], Union[str, int]] @dataclass @@ -37,6 +38,7 @@ class WitnessWordedHint: area: Optional[str] = None area_amount: Optional[int] = None area_hunt_panels: Optional[int] = None + vague_location_hint: bool = False def get_always_hint_items(world: "WitnessWorld") -> List[str]: @@ -170,6 +172,51 @@ def get_priority_hint_locations(world: "WitnessWorld") -> List[str]: return priority +def try_getting_location_group_for_location(world: "WitnessWorld", hint_loc: Location) -> Tuple[str, str]: + allow_regions = world.options.vague_hints == "experimental" + + possible_location_groups = { + group_name: group_locations + for group_name, group_locations in world.multiworld.worlds[hint_loc.player].location_name_groups.items() + if hint_loc.name in group_locations + } + + locations_in_that_world = { + location.name for location in world.multiworld.get_locations(hint_loc.player) if not location.is_event + } + + valid_location_groups: Dict[str, int] = {} + + # Find valid location groups. + for group, locations in possible_location_groups.items(): + if group == "Everywhere": + continue + present_locations = sum(location in locations_in_that_world for location in locations) + valid_location_groups[group] = present_locations + + # If there are valid location groups, use a random one. + if valid_location_groups: + # If there are location groups with more than 1 location, remove any that only have 1. + if any(num_locs > 1 for num_locs in valid_location_groups.values()): + valid_location_groups = {name: num_locs for name, num_locs in valid_location_groups.items() if num_locs > 1} + + location_groups_with_weights = { + # Listen. Just don't worry about it. :))) + location_group: (x ** 0.6) * math.e ** (- (x / 7) ** 0.6) if x > 6 else x / 6 + for location_group, x in valid_location_groups.items() + } + + location_groups = list(location_groups_with_weights.keys()) + weights = list(location_groups_with_weights.values()) + + return world.random.choices(location_groups, weights, k=1)[0], "Group" + + if allow_regions: + return cast(Region, hint_loc.parent_region).name, "Region" + + return "Everywhere", "Everywhere" + + def word_direct_hint(world: "WitnessWorld", hint: WitnessLocationHint) -> WitnessWordedHint: location_name = hint.location.name if hint.location.player != world.player: @@ -184,12 +231,37 @@ def word_direct_hint(world: "WitnessWorld", hint: WitnessLocationHint) -> Witnes if item.player != world.player: item_name += " (" + world.multiworld.get_player_name(item.player) + ")" - if hint.hint_came_from_location: - hint_text = f"{location_name} contains {item_name}." - else: - hint_text = f"{item_name} can be found at {location_name}." + hint_text = "" + area: Optional[str] = None + + if world.options.vague_hints: + chosen_group, group_type = try_getting_location_group_for_location(world, hint.location) - return WitnessWordedHint(hint_text, hint.location) + if hint.location.player == world.player: + area = chosen_group + + # local locations should only ever return a location group, as Witness defines groups for every location. + hint_text = f"{item_name} can be found in the {area} area." + else: + player_name = world.multiworld.get_player_name(hint.location.player) + + if group_type == "Everywhere": + location_name = f"a location in {player_name}'s world" + elif group_type == "Group": + location_name = f"a \"{chosen_group}\" location in {player_name}'s world" + elif group_type == "Region": + if chosen_group == "Menu": + location_name = f"a location near the start of {player_name}'s game (\"Menu\" region)" + else: + location_name = f"a location in {player_name}'s \"{chosen_group}\" region" + + if hint_text == "": + if hint.hint_came_from_location: + hint_text = f"{location_name} contains {item_name}." + else: + hint_text = f"{item_name} can be found at {location_name}." + + return WitnessWordedHint(hint_text, hint.location, area=area, vague_location_hint=bool(world.options.vague_hints)) def hint_from_item(world: "WitnessWorld", item_name: str, @@ -224,45 +296,55 @@ def hint_from_location(world: "WitnessWorld", location: str) -> Optional[Witness return WitnessLocationHint(world.get_location(location), True) -def get_items_and_locations_in_random_order(world: "WitnessWorld", - own_itempool: List["WitnessItem"]) -> Tuple[List[str], List[str]]: - prog_items_in_this_world = sorted( +def get_item_and_location_names_in_random_order(world: "WitnessWorld", + own_itempool: List["WitnessItem"]) -> Tuple[List[str], List[str]]: + prog_item_names_in_this_world = [ item.name for item in own_itempool if item.advancement and item.code and item.location - ) - locations_in_this_world = sorted( - location.name for location in world.multiworld.get_locations(world.player) - if location.address and location.progress_type != LocationProgressType.EXCLUDED - ) + ] + world.random.shuffle(prog_item_names_in_this_world) - world.random.shuffle(prog_items_in_this_world) + locations_in_this_world = [ + location for location in world.multiworld.get_locations(world.player) + if location.item and not location.is_event and location.progress_type != LocationProgressType.EXCLUDED + ] world.random.shuffle(locations_in_this_world) - return prog_items_in_this_world, locations_in_this_world + if world.options.vague_hints: + locations_in_this_world.sort(key=lambda location: cast(Item, location.item).advancement) + + location_names_in_this_world = [location.name for location in locations_in_this_world] + + return prog_item_names_in_this_world, location_names_in_this_world def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List["WitnessItem"], already_hinted_locations: Set[Location] ) -> Tuple[List[WitnessLocationHint], List[WitnessLocationHint]]: - prog_items_in_this_world, loc_in_this_world = get_items_and_locations_in_random_order(world, own_itempool) - always_locations = [ - location for location in get_always_hint_locations(world) - if location in loc_in_this_world - ] + prog_items_in_this_world, loc_in_this_world = get_item_and_location_names_in_random_order(world, own_itempool) + always_items = [ item for item in get_always_hint_items(world) if item in prog_items_in_this_world ] - priority_locations = [ - location for location in get_priority_hint_locations(world) - if location in loc_in_this_world - ] priority_items = [ item for item in get_priority_hint_items(world) if item in prog_items_in_this_world ] + if world.options.vague_hints: + always_locations, priority_locations = [], [] + else: + always_locations = [ + location for location in get_always_hint_locations(world) + if location in loc_in_this_world + ] + priority_locations = [ + location for location in get_priority_hint_locations(world) + if location in loc_in_this_world + ] + # Get always and priority location/item hints always_location_hints = {hint_from_location(world, location) for location in always_locations} always_item_hints = {hint_from_item(world, item, own_itempool) for item in always_items} @@ -291,7 +373,7 @@ def make_always_and_priority_hints(world: "WitnessWorld", own_itempool: List["Wi def make_extra_location_hints(world: "WitnessWorld", hint_amount: int, own_itempool: List["WitnessItem"], already_hinted_locations: Set[Location], hints_to_use_first: List[WitnessLocationHint], unhinted_locations_for_hinted_areas: Dict[str, Set[Location]]) -> List[WitnessWordedHint]: - prog_items_in_this_world, locations_in_this_world = get_items_and_locations_in_random_order(world, own_itempool) + prog_items_in_this_world, locations_in_this_world = get_item_and_location_names_in_random_order(world, own_itempool) next_random_hint_is_location = world.random.randrange(0, 2) @@ -384,7 +466,7 @@ def get_hintable_areas(world: "WitnessWorld") -> Tuple[Dict[str, List[Location]] for region in static_witness_logic.ALL_AREAS_BY_NAME[area]["regions"] if region in world.player_regions.created_region_names ] - locations = [location for region in regions for location in region.get_locations() if location.address] + locations = [location for region in regions for location in region.get_locations() if not location.is_event] if locations: locations_per_area[area] = locations @@ -615,9 +697,7 @@ def get_compact_hint_args(hint: WitnessWordedHint, local_player_number: int) -> """ # Is Area Hint - if hint.area is not None: - assert hint.area_amount is not None, "Area hint had an undefined progression amount." - + if hint.area_amount is not None: area_amount = hint.area_amount hunt_panels = hint.area_hunt_panels @@ -632,7 +712,11 @@ def get_compact_hint_args(hint: WitnessWordedHint, local_player_number: int) -> # Is location hint if location and location.address is not None: - return location.address, location.player + if hint.vague_location_hint and location.player == local_player_number: + assert hint.area is not None # A local vague location hint should have an area argument + return location.address, "containing_area:" + hint.area + else: + return location.address, location.player # Scouting does not matter for other players (currently) # Is junk / undefined hint return -1, local_player_number diff --git a/worlds/witness/options.py b/worlds/witness/options.py index bdeccfe3b2db..6f7222d5f9b4 100644 --- a/worlds/witness/options.py +++ b/worlds/witness/options.py @@ -331,6 +331,25 @@ class HintAmount(Range): default = 12 +class VagueHints(Choice): + """Make Location Hints a bit more vague, where they only tell you about the general area the item is in. + Area Hints will be generated as normal. + + If set to "stable", only location groups will be used. If location groups aren't implemented for the game your item ended up in, your hint will instead only tell you that the item is "somewhere in" that game. + If set to "experimental", region names will be eligible as well, and you will never receive a "somewhere in" hint. Keep in mind that region names are not always intended to be comprehensible to players — only turn this on if you are okay with a bit of chaos. + + + The distinction does not matter in single player, as Witness implements location groups for every location. + + Also, please don't pester any devs about implementing location groups. Bring it up nicely, accept their response even if it is "No". + """ + display_name = "Vague Hints" + + option_off = 0 + option_stable = 1 + option_experimental = 2 + + class AreaHintPercentage(Range): """ There are two types of hints for The Witness. @@ -400,6 +419,7 @@ class TheWitnessOptions(PerGameCommonOptions): trap_weights: TrapWeights puzzle_skip_amount: PuzzleSkipAmount hint_amount: HintAmount + vague_hints: VagueHints area_hint_percentage: AreaHintPercentage laser_hints: LaserHints death_link: DeathLink @@ -442,6 +462,7 @@ class TheWitnessOptions(PerGameCommonOptions): ]), OptionGroup("Hints", [ HintAmount, + VagueHints, AreaHintPercentage, LaserHints ]), diff --git a/worlds/witness/test/test_panel_hunt.py b/worlds/witness/test/test_panel_hunt.py index 7b405f29ec1d..2fc16f787e67 100644 --- a/worlds/witness/test/test_panel_hunt.py +++ b/worlds/witness/test/test_panel_hunt.py @@ -23,7 +23,7 @@ def test_correct_panels_were_picked(self): for _ in range(100): state_100.collect(panel_hunt_item, True) - state_100.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")]) + state_100.sweep_for_events([self.world.get_location("Tutorial Gate Open Solved")]) self.assertTrue(self.multiworld.completion_condition[self.player](state_100)) @@ -33,7 +33,7 @@ def test_correct_panels_were_picked(self): for _ in range(99): state_99.collect(panel_hunt_item, True) - state_99.sweep_for_events(False, [self.world.get_location("Tutorial Gate Open Solved")]) + state_99.sweep_for_events([self.world.get_location("Tutorial Gate Open Solved")]) self.assertFalse(self.multiworld.completion_condition[self.player](state_99)) diff --git a/worlds/witness/test/test_roll_other_options.py b/worlds/witness/test/test_roll_other_options.py index 71743c326038..3912ce252e53 100644 --- a/worlds/witness/test/test_roll_other_options.py +++ b/worlds/witness/test/test_roll_other_options.py @@ -34,6 +34,7 @@ class TestMiscOptions(WitnessTestBase): "laser_hints": True, "hint_amount": 40, "area_hint_percentage": 100, + "vague_hints": "experimental", } From 0e6e35974735764a99e06eb7ce1d63b11bc32e83 Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Mon, 19 Aug 2024 21:59:29 -0500 Subject: [PATCH 5/5] Mega Man 2: Implement New Game (#3256) * initial (broken) commit * small work on init * Update Items.py * beginning work, some rom patches * commit progress from bh branch * deathlink, fix soft-reset kill, e-tank loss * begin work on targeting new bhclient * write font * definitely didn't forget to add the other two hashes no * update to modern options, begin colors * fix 6th letter bug * palette shuffle + logic rewrite * fix a bunch of pointers * fix color changes, deathlink, and add wily 5 req * adjust weapon weakness generation * Update Rules.py * attempt wily 5 softlock fix * add explicit test for rbm weaknesses * fix difficulty and hard reset * fix connect deathlink and off by one item color * fix atomic fire again * de-jank deathlink * rewrite wily5 rule * fix rare solo-gen fill issue, hopefully * Update Client.py * fix wily 5 requirements * undo fill hook * fix picopico-kun rules * for real this time * update minimum damage requirement * begin move to procedure patch * finish move to APPP, allow rando boobeam, color updates * fix color bug, UT support? * what do you mean I forgot the procedure * fix UT? * plando weakness and fixes * sfx when item received, more time stopper edge cases * Update test_weakness.py * fix rules and color bug * fix color bug, support reduced flashing * major world overhaul * Update Locations.py * fix first found bugs * mypy cleanup * headerless roms * Update Rom.py * further cleanup * work on energylink * el fixes * update to energylink 2.0 packet * energylink balancing * potentially break other clients, more balancing * Update Items.py * remove startup change from basepatch we write that in patch, since we also need to clean the area before applying * el balancing and feedback * hopefully less test failures? * implement world version check * add weapon/health option * Update Rom.py * x/x2 * specials * Update Color.py * Update Options.py * finally apply location groups * bump minor version number instead * fix duplicate stage sends * validate wily 5, tests * see if renaming fixes * add shuffled weakness * remove passwords * refresh rbm select, fix wily 5 validation * forgot we can't check 0 * oops I broke the basepatch (remove failing test later) * fix solo gen fill error? * fix webhost patch recognition * fix imports, basepatch * move to flexibility metric for boss validation * special case boobeam trap * block strobe on stage select init * more energylink balancing * bump world version * wily HP inaccurate in validation * fix validation edge case * save last completed wily to data storage * mypy and pep8 cleanup * fix file browse validation * fix test failure, add enemy weakness * remove test seed * update enemy damage * inno setup * Update en_Mega Man 2.md * setup guide * Update en_Mega Man 2.md * finish plando weakness section * starting rbm edge case * remove * imports * properly wrap later weakness additions in regen playthrough * fix import * forgot readme * remove time stopper special casing since we moved to proper wily 5 validation, this special casing is no longer important * properly type added locations * Update CODEOWNERS * add animation reduction * deprioritize Time Stopper in rush checks * special case wily phase 1 * fix key error * forgot the test * music and general cleanup * the great rename * fix import * thanks pycharm * reorder palette shuffle * account for alien on shuffled weakness * apply suggestions * fix seedbleed * fix invalid buster passthrough * fix weakness landing beneath required amount * fix failsafe * finish music * fix Time Stopper on Flash/Alien * asar pls * Apply suggestions from code review Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> * world helpers * init cleanup * apostrophes * clearer wording * mypy and cleanup * options doc cleanup * Update rom.py * rules cleanup * Update __init__.py * Update __init__.py * move to defaultdict * cleanup world helpers * Update __init__.py * remove unnecessary line from fill hook * forgot the other one * apply code review * remove collect * Update rules.py * forgot another --------- Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> --- README.md | 1 + docs/CODEOWNERS | 3 + inno_setup.iss | 5 + worlds/mm2/__init__.py | 290 +++++++++ worlds/mm2/client.py | 562 +++++++++++++++++ worlds/mm2/color.py | 276 +++++++++ worlds/mm2/data/mm2_basepatch.bsdiff4 | Bin 0 -> 1440 bytes worlds/mm2/docs/en_Mega Man 2.md | 114 ++++ worlds/mm2/docs/setup_en.md | 53 ++ worlds/mm2/items.py | 72 +++ worlds/mm2/locations.py | 239 +++++++ worlds/mm2/names.py | 114 ++++ worlds/mm2/options.py | 229 +++++++ worlds/mm2/rom.py | 415 +++++++++++++ worlds/mm2/rules.py | 319 ++++++++++ worlds/mm2/src/mm2_basepatch.asm | 861 ++++++++++++++++++++++++++ worlds/mm2/src/mm2font.dat | Bin 0 -> 416 bytes worlds/mm2/src/mm2titlefont.dat | Bin 0 -> 160 bytes worlds/mm2/test/__init__.py | 5 + worlds/mm2/test/test_access.py | 47 ++ worlds/mm2/test/test_weakness.py | 93 +++ worlds/mm2/text.py | 90 +++ 22 files changed, 3788 insertions(+) create mode 100644 worlds/mm2/__init__.py create mode 100644 worlds/mm2/client.py create mode 100644 worlds/mm2/color.py create mode 100644 worlds/mm2/data/mm2_basepatch.bsdiff4 create mode 100644 worlds/mm2/docs/en_Mega Man 2.md create mode 100644 worlds/mm2/docs/setup_en.md create mode 100644 worlds/mm2/items.py create mode 100644 worlds/mm2/locations.py create mode 100644 worlds/mm2/names.py create mode 100644 worlds/mm2/options.py create mode 100644 worlds/mm2/rom.py create mode 100644 worlds/mm2/rules.py create mode 100644 worlds/mm2/src/mm2_basepatch.asm create mode 100644 worlds/mm2/src/mm2font.dat create mode 100644 worlds/mm2/src/mm2titlefont.dat create mode 100644 worlds/mm2/test/__init__.py create mode 100644 worlds/mm2/test/test_access.py create mode 100644 worlds/mm2/test/test_weakness.py create mode 100644 worlds/mm2/text.py diff --git a/README.md b/README.md index a2e9d3e5e5a3..0d9a41de9f1a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Currently, the following games are supported: * A Hat in Time * Old School Runescape * Kingdom Hearts 1 +* Mega Man 2 For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index bba79c649fc1..6a3c8f45c174 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -106,6 +106,9 @@ # Minecraft /worlds/minecraft/ @KonoTyran @espeon65536 +# Mega Man 2 +/worlds/mm2/ @Silvris + # MegaMan Battle Network 3 /worlds/mmbn3/ @digiholic diff --git a/inno_setup.iss b/inno_setup.iss index f097500f7d7d..3bb76fc40abe 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: ""; +Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: ""; + Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; diff --git a/worlds/mm2/__init__.py b/worlds/mm2/__init__.py new file mode 100644 index 000000000000..07e1823f9387 --- /dev/null +++ b/worlds/mm2/__init__.py @@ -0,0 +1,290 @@ +import hashlib +import logging +from copy import deepcopy +from typing import Dict, Any, TYPE_CHECKING, Optional, Sequence, Tuple, ClassVar, List + +from BaseClasses import Tutorial, ItemClassification, MultiWorld, Item, Location +from worlds.AutoWorld import World, WebWorld +from .names import (dr_wily, heat_man_stage, air_man_stage, wood_man_stage, bubble_man_stage, quick_man_stage, + flash_man_stage, metal_man_stage, crash_man_stage) +from .items import (item_table, item_names, MM2Item, filler_item_weights, robot_master_weapon_table, + stage_access_table, item_item_table, lookup_item_to_id) +from .locations import (MM2Location, mm2_regions, MM2Region, energy_pickups, etank_1ups, lookup_location_to_id, + location_groups) +from .rom import patch_rom, MM2ProcedurePatch, MM2LCHASH, PROTEUSHASH, MM2VCHASH, MM2NESHASH +from .options import MM2Options, Consumables +from .client import MegaMan2Client +from .rules import set_rules, weapon_damage, robot_masters, weapons_to_name, minimum_weakness_requirement +import os +import threading +import base64 +import settings +logger = logging.getLogger("Mega Man 2") + +if TYPE_CHECKING: + from BaseClasses import CollectionState + + +class MM2Settings(settings.Group): + class RomFile(settings.UserFilePath): + """File name of the MM2 EN rom""" + description = "Mega Man 2 ROM File" + copy_to: Optional[str] = "Mega Man 2 (USA).nes" + md5s = [MM2NESHASH, MM2VCHASH, MM2LCHASH, PROTEUSHASH] + + def browse(self: settings.T, + filetypes: Optional[Sequence[Tuple[str, Sequence[str]]]] = None, + **kwargs: Any) -> Optional[settings.T]: + if not filetypes: + file_types = [("NES", [".nes"]), ("Program", [".exe"])] # LC1 is only a windows executable, no linux + return super().browse(file_types, **kwargs) + else: + return super().browse(filetypes, **kwargs) + + @classmethod + def validate(cls, path: str) -> None: + """Try to open and validate file against hashes""" + with open(path, "rb", buffering=0) as f: + try: + f.seek(0) + if f.read(4) == b"NES\x1A": + f.seek(16) + else: + f.seek(0) + cls._validate_stream_hashes(f) + base_rom_bytes = f.read() + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() == PROTEUSHASH: + # we need special behavior here + cls.copy_to = None + except ValueError: + raise ValueError(f"File hash does not match for {path}") + + rom_file: RomFile = RomFile(RomFile.copy_to) + + +class MM2WebWorld(WebWorld): + theme = "partyTime" + tutorials = [ + + Tutorial( + "Multiworld Setup Guide", + "A guide to setting up the Mega Man 2 randomizer connected to an Archipelago Multiworld.", + "English", + "setup_en.md", + "setup/en", + ["Silvris"] + ) + ] + + +class MM2World(World): + """ + In the year 200X, following his prior defeat by Mega Man, the evil Dr. Wily has returned to take over the world with + his own group of Robot Masters. Mega Man once again sets out to defeat the eight Robot Masters and stop Dr. Wily. + + """ + + game = "Mega Man 2" + settings: ClassVar[MM2Settings] + options_dataclass = MM2Options + options: MM2Options + item_name_to_id = lookup_item_to_id + location_name_to_id = lookup_location_to_id + item_name_groups = item_names + location_name_groups = location_groups + web = MM2WebWorld() + rom_name: bytearray + world_version: Tuple[int, int, int] = (0, 3, 1) + wily_5_weapons: Dict[int, List[int]] + + def __init__(self, world: MultiWorld, player: int): + self.rom_name = bytearray() + self.rom_name_available_event = threading.Event() + super().__init__(world, player) + self.weapon_damage = deepcopy(weapon_damage) + self.wily_5_weapons = {} + + def create_regions(self) -> None: + menu = MM2Region("Menu", self.player, self.multiworld) + self.multiworld.regions.append(menu) + for region in mm2_regions: + stage = MM2Region(region, self.player, self.multiworld) + required_items = mm2_regions[region][0] + locations = mm2_regions[region][1] + prev_stage = mm2_regions[region][2] + if prev_stage is None: + menu.connect(stage, f"To {region}", + lambda state, items=required_items: state.has_all(items, self.player)) + else: + old_stage = self.get_region(prev_stage) + old_stage.connect(stage, f"To {region}", + lambda state, items=required_items: state.has_all(items, self.player)) + stage.add_locations(locations, MM2Location) + for location in stage.get_locations(): + if location.address is None and location.name != dr_wily: + location.place_locked_item(MM2Item(location.name, ItemClassification.progression, + None, self.player)) + if region in etank_1ups and self.options.consumables in (Consumables.option_1up_etank, + Consumables.option_all): + stage.add_locations(etank_1ups[region], MM2Location) + if region in energy_pickups and self.options.consumables in (Consumables.option_weapon_health, + Consumables.option_all): + stage.add_locations(energy_pickups[region], MM2Location) + self.multiworld.regions.append(stage) + + def create_item(self, name: str) -> MM2Item: + item = item_table[name] + classification = ItemClassification.filler + if item.progression: + classification = ItemClassification.progression_skip_balancing \ + if item.skip_balancing else ItemClassification.progression + if item.useful: + classification |= ItemClassification.useful + return MM2Item(name, classification, item.code, self.player) + + def get_filler_item_name(self) -> str: + return self.random.choices(list(filler_item_weights.keys()), + weights=list(filler_item_weights.values()))[0] + + def create_items(self) -> None: + itempool = [] + # grab first robot master + robot_master = self.item_id_to_name[0x880101 + self.options.starting_robot_master.value] + self.multiworld.push_precollected(self.create_item(robot_master)) + itempool.extend([self.create_item(name) for name in stage_access_table.keys() + if name != robot_master]) + itempool.extend([self.create_item(name) for name in robot_master_weapon_table.keys()]) + itempool.extend([self.create_item(name) for name in item_item_table.keys()]) + total_checks = 24 + if self.options.consumables in (Consumables.option_1up_etank, + Consumables.option_all): + total_checks += 20 + if self.options.consumables in (Consumables.option_weapon_health, + Consumables.option_all): + total_checks += 27 + remaining = total_checks - len(itempool) + itempool.extend([self.create_item(name) + for name in self.random.choices(list(filler_item_weights.keys()), + weights=list(filler_item_weights.values()), + k=remaining)]) + self.multiworld.itempool += itempool + + set_rules = set_rules + + def generate_early(self) -> None: + if (not self.options.yoku_jumps + and self.options.starting_robot_master == "heat_man") or \ + (not self.options.enable_lasers + and self.options.starting_robot_master == "quick_man"): + robot_master_pool = [1, 2, 3, 5, 6, 7, ] + if self.options.yoku_jumps: + robot_master_pool.append(0) + if self.options.enable_lasers: + robot_master_pool.append(4) + self.options.starting_robot_master.value = self.random.choice(robot_master_pool) + logger.warning( + f"Mega Man 2 ({self.player_name}): " + f"Incompatible starting Robot Master, changing to " + f"{self.options.starting_robot_master.current_key.replace('_', ' ').title()}") + + def generate_basic(self) -> None: + goal_location = self.get_location(dr_wily) + goal_location.place_locked_item(MM2Item("Victory", ItemClassification.progression, None, self.player)) + self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player) + + def fill_hook(self, + progitempool: List["Item"], + usefulitempool: List["Item"], + filleritempool: List["Item"], + fill_locations: List["Location"]) -> None: + # on a solo gen, fill can try to force Wily into sphere 2, but for most generations this is impossible + # since MM2 can have a 2 item sphere 1, and 3 items are required for Wily + if self.multiworld.players > 1: + return # Don't need to change anything on a multi gen, fill should be able to solve it with a 4 sphere 1 + rbm_to_item = { + 0: heat_man_stage, + 1: air_man_stage, + 2: wood_man_stage, + 3: bubble_man_stage, + 4: quick_man_stage, + 5: flash_man_stage, + 6: metal_man_stage, + 7: crash_man_stage + } + affected_rbm = [2, 3] # Wood and Bubble will always have this happen + possible_rbm = [1, 5] # Air and Flash are always valid targets, due to Item 2/3 receive + if self.options.consumables: + possible_rbm.append(6) # Metal has 3 consumables + possible_rbm.append(7) # Crash has 3 consumables + if self.options.enable_lasers: + possible_rbm.append(4) # Quick has a lot of consumables, but needs logical time stopper if not enabled + else: + affected_rbm.extend([6, 7]) # only two checks on non consumables + if self.options.yoku_jumps: + possible_rbm.append(0) # Heat has 3 locations always, but might need 2 items logically + if self.options.starting_robot_master.value in affected_rbm: + rbm_names = list(map(lambda s: rbm_to_item[s], possible_rbm)) + valid_second = [item for item in progitempool + if item.name in rbm_names + and item.player == self.player] + placed_item = self.random.choice(valid_second) + rbm_defeated = (f"{robot_masters[self.options.starting_robot_master.value].replace(' Defeated', '')}" + f" - Defeated") + rbm_location = self.get_location(rbm_defeated) + rbm_location.place_locked_item(placed_item) + progitempool.remove(placed_item) + fill_locations.remove(rbm_location) + target_rbm = (placed_item.code & 0xF) - 1 + if self.options.strict_weakness or (self.options.random_weakness + and not (self.weapon_damage[0][target_rbm] > 0)): + # we need to find a weakness for this boss + weaknesses = [weapon for weapon in range(1, 9) + if self.weapon_damage[weapon][target_rbm] >= minimum_weakness_requirement[weapon]] + weapons = list(map(lambda s: weapons_to_name[s], weaknesses)) + valid_weapons = [item for item in progitempool + if item.name in weapons + and item.player == self.player] + placed_weapon = self.random.choice(valid_weapons) + weapon_name = next(name for name, idx in lookup_location_to_id.items() + if idx == 0x880101 + self.options.starting_robot_master.value) + weapon_location = self.get_location(weapon_name) + weapon_location.place_locked_item(placed_weapon) + progitempool.remove(placed_weapon) + fill_locations.remove(weapon_location) + + def generate_output(self, output_directory: str) -> None: + try: + patch = MM2ProcedurePatch(player=self.player, player_name=self.player_name) + patch_rom(self, patch) + + self.rom_name = patch.name + + patch.write(os.path.join(output_directory, + f"{self.multiworld.get_out_file_name_base(self.player)}{patch.patch_file_ending}")) + except Exception: + raise + finally: + self.rom_name_available_event.set() # make sure threading continues and errors are collected + + def fill_slot_data(self) -> Dict[str, Any]: + return { + "death_link": self.options.death_link.value, + "weapon_damage": self.weapon_damage, + "wily_5_weapons": self.wily_5_weapons, + } + + def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Dict[str, Any]: + local_weapon = {int(key): value for key, value in slot_data["weapon_damage"].items()} + local_wily = {int(key): value for key, value in slot_data["wily_5_weapons"].items()} + return {"weapon_damage": local_weapon, "wily_5_weapons": local_wily} + + def modify_multidata(self, multidata: Dict[str, Any]) -> None: + # wait for self.rom_name to be available. + self.rom_name_available_event.wait() + rom_name = getattr(self, "rom_name", None) + # we skip in case of error, so that the original error in the output thread is the one that gets raised + if rom_name: + new_name = base64.b64encode(bytes(self.rom_name)).decode() + multidata["connect_names"][new_name] = multidata["connect_names"][self.player_name] diff --git a/worlds/mm2/client.py b/worlds/mm2/client.py new file mode 100644 index 000000000000..aaa0813c763a --- /dev/null +++ b/worlds/mm2/client.py @@ -0,0 +1,562 @@ +import logging +import time +from enum import IntEnum +from base64 import b64encode +from typing import TYPE_CHECKING, Dict, Tuple, List, Optional, Any +from NetUtils import ClientStatus, color, NetworkItem +from worlds._bizhawk.client import BizHawkClient + +if TYPE_CHECKING: + from worlds._bizhawk.context import BizHawkClientContext, BizHawkClientCommandProcessor + +nes_logger = logging.getLogger("NES") +logger = logging.getLogger("Client") + +MM2_ROBOT_MASTERS_UNLOCKED = 0x8A +MM2_ROBOT_MASTERS_DEFEATED = 0x8B +MM2_ITEMS_ACQUIRED = 0x8C +MM2_LAST_WILY = 0x8D +MM2_RECEIVED_ITEMS = 0x8E +MM2_DEATHLINK = 0x8F +MM2_ENERGYLINK = 0x90 +MM2_RBM_STROBE = 0x91 +MM2_WEAPONS_UNLOCKED = 0x9A +MM2_ITEMS_UNLOCKED = 0x9B +MM2_WEAPON_ENERGY = 0x9C +MM2_E_TANKS = 0xA7 +MM2_LIVES = 0xA8 +MM2_DIFFICULTY = 0xCB +MM2_HEALTH = 0x6C0 +MM2_COMPLETED_STAGES = 0x770 +MM2_CONSUMABLES = 0x780 + +MM2_SFX_QUEUE = 0x580 +MM2_SFX_STROBE = 0x66 + +MM2_CONSUMABLE_TABLE: Dict[int, Tuple[int, int]] = { + # Item: (byte offset, bit mask) + 0x880201: (0, 8), + 0x880202: (16, 1), + 0x880203: (16, 2), + 0x880204: (16, 4), + 0x880205: (16, 8), + 0x880206: (16, 16), + 0x880207: (16, 32), + 0x880208: (16, 64), + 0x880209: (16, 128), + 0x88020A: (20, 1), + 0x88020B: (20, 4), + 0x88020C: (20, 64), + 0x88020D: (21, 1), + 0x88020E: (21, 2), + 0x88020F: (21, 4), + 0x880210: (24, 1), + 0x880211: (24, 2), + 0x880212: (24, 4), + 0x880213: (28, 1), + 0x880214: (28, 2), + 0x880215: (28, 4), + 0x880216: (33, 4), + 0x880217: (33, 8), + 0x880218: (37, 8), + 0x880219: (37, 16), + 0x88021A: (38, 1), + 0x88021B: (38, 2), + 0x880227: (38, 4), + 0x880228: (38, 32), + 0x880229: (38, 128), + 0x88022A: (39, 4), + 0x88022B: (39, 2), + 0x88022C: (39, 1), + 0x88022D: (38, 64), + 0x88022E: (38, 16), + 0x88022F: (38, 8), + 0x88021C: (39, 32), + 0x88021D: (39, 64), + 0x88021E: (39, 128), + 0x88021F: (41, 16), + 0x880220: (42, 2), + 0x880221: (42, 4), + 0x880222: (42, 8), + 0x880223: (46, 1), + 0x880224: (46, 2), + 0x880225: (46, 4), + 0x880226: (46, 8), +} + + +class MM2EnergyLinkType(IntEnum): + Life = 0 + AtomicFire = 1 + AirShooter = 2 + LeafShield = 3 + BubbleLead = 4 + QuickBoomerang = 5 + TimeStopper = 6 + MetalBlade = 7 + CrashBomber = 8 + Item1 = 9 + Item2 = 10 + Item3 = 11 + OneUP = 12 + + +request_to_name: Dict[str, str] = { + "HP": "health", + "AF": "Atomic Fire energy", + "AS": "Air Shooter energy", + "LS": "Leaf Shield energy", + "BL": "Bubble Lead energy", + "QB": "Quick Boomerang energy", + "TS": "Time Stopper energy", + "MB": "Metal Blade energy", + "CB": "Crash Bomber energy", + "I1": "Item 1 energy", + "I2": "Item 2 energy", + "I3": "Item 3 energy", + "1U": "lives" +} + +HP_EXCHANGE_RATE = 500000000 +WEAPON_EXCHANGE_RATE = 250000000 +ONEUP_EXCHANGE_RATE = 14000000000 + + +def cmd_pool(self: "BizHawkClientCommandProcessor") -> None: + """Check the current pool of EnergyLink, and requestable refills from it.""" + if self.ctx.game != "Mega Man 2": + logger.warning("This command can only be used when playing Mega Man 2.") + return + if not self.ctx.server or not self.ctx.slot: + logger.warning("You must be connected to a server to use this command.") + return + energylink = self.ctx.stored_data.get(f"EnergyLink{self.ctx.team}", 0) + health_points = energylink // HP_EXCHANGE_RATE + weapon_points = energylink // WEAPON_EXCHANGE_RATE + lives = energylink // ONEUP_EXCHANGE_RATE + logger.info(f"Healing available: {health_points}\n" + f"Weapon refill available: {weapon_points}\n" + f"Lives available: {lives}") + + +def cmd_request(self: "BizHawkClientCommandProcessor", amount: str, target: str) -> None: + from worlds._bizhawk.context import BizHawkClientContext + """Request a refill from EnergyLink.""" + if self.ctx.game != "Mega Man 2": + logger.warning("This command can only be used when playing Mega Man 2.") + return + if not self.ctx.server or not self.ctx.slot: + logger.warning("You must be connected to a server to use this command.") + return + valid_targets: Dict[str, MM2EnergyLinkType] = { + "HP": MM2EnergyLinkType.Life, + "AF": MM2EnergyLinkType.AtomicFire, + "AS": MM2EnergyLinkType.AirShooter, + "LS": MM2EnergyLinkType.LeafShield, + "BL": MM2EnergyLinkType.BubbleLead, + "QB": MM2EnergyLinkType.QuickBoomerang, + "TS": MM2EnergyLinkType.TimeStopper, + "MB": MM2EnergyLinkType.MetalBlade, + "CB": MM2EnergyLinkType.CrashBomber, + "I1": MM2EnergyLinkType.Item1, + "I2": MM2EnergyLinkType.Item2, + "I3": MM2EnergyLinkType.Item3, + "1U": MM2EnergyLinkType.OneUP + } + if target.upper() not in valid_targets: + logger.warning(f"Unrecognized target {target.upper()}. Available targets: {', '.join(valid_targets.keys())}") + return + ctx = self.ctx + assert isinstance(ctx, BizHawkClientContext) + client = ctx.client_handler + assert isinstance(client, MegaMan2Client) + client.refill_queue.append((valid_targets[target.upper()], int(amount))) + logger.info(f"Restoring {amount} {request_to_name[target.upper()]}.") + + +def cmd_autoheal(self) -> None: + """Enable auto heal from EnergyLink.""" + if self.ctx.game != "Mega Man 2": + logger.warning("This command can only be used when playing Mega Man 2.") + return + if not self.ctx.server or not self.ctx.slot: + logger.warning("You must be connected to a server to use this command.") + return + else: + assert isinstance(self.ctx.client_handler, MegaMan2Client) + if self.ctx.client_handler.auto_heal: + self.ctx.client_handler.auto_heal = False + logger.info(f"Auto healing disabled.") + else: + self.ctx.client_handler.auto_heal = True + logger.info(f"Auto healing enabled.") + + +def get_sfx_writes(sfx: int) -> Tuple[Tuple[int, bytes, str], ...]: + return (MM2_SFX_QUEUE, sfx.to_bytes(1, 'little'), "RAM"), (MM2_SFX_STROBE, 0x01.to_bytes(1, "little"), "RAM") + + +class MegaMan2Client(BizHawkClient): + game = "Mega Man 2" + system = "NES" + patch_suffix = ".apmm2" + item_queue: List[NetworkItem] = [] + pending_death_link: bool = False + # default to true, as we don't want to send a deathlink until Mega Man's HP is initialized once + sending_death_link: bool = True + death_link: bool = False + energy_link: bool = False + rom: Optional[bytes] = None + weapon_energy: int = 0 + health_energy: int = 0 + auto_heal: bool = False + refill_queue: List[Tuple[MM2EnergyLinkType, int]] = [] + last_wily: Optional[int] = None # default to wily 1 + + async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: + from worlds._bizhawk import RequestFailedError, read + from . import MM2World + + try: + game_name, version = (await read(ctx.bizhawk_ctx, [(0x3FFB0, 21, "PRG ROM"), + (0x3FFC8, 3, "PRG ROM")])) + if game_name[:3] != b"MM2" or version != bytes(MM2World.world_version): + if game_name[:3] == b"MM2": + # I think this is an easier check than the other? + older_version = "0.2.1" if version == b"\xFF\xFF\xFF" else f"{version[0]}.{version[1]}.{version[2]}" + logger.warning(f"This Mega Man 2 patch was generated for an different version of the apworld. " + f"Please use that version to connect instead.\n" + f"Patch version: ({older_version})\n" + f"Client version: ({'.'.join([str(i) for i in MM2World.world_version])})") + if "pool" in ctx.command_processor.commands: + ctx.command_processor.commands.pop("pool") + if "request" in ctx.command_processor.commands: + ctx.command_processor.commands.pop("request") + if "autoheal" in ctx.command_processor.commands: + ctx.command_processor.commands.pop("autoheal") + return False + except UnicodeDecodeError: + return False + except RequestFailedError: + return False # Should verify on the next pass + + ctx.game = self.game + self.rom = game_name + ctx.items_handling = 0b111 + ctx.want_slot_data = False + deathlink = (await read(ctx.bizhawk_ctx, [(0x3FFC5, 1, "PRG ROM")]))[0][0] + if deathlink & 0x01: + self.death_link = True + if deathlink & 0x02: + self.energy_link = True + + if self.energy_link: + if "pool" not in ctx.command_processor.commands: + ctx.command_processor.commands["pool"] = cmd_pool + if "request" not in ctx.command_processor.commands: + ctx.command_processor.commands["request"] = cmd_request + if "autoheal" not in ctx.command_processor.commands: + ctx.command_processor.commands["autoheal"] = cmd_autoheal + + return True + + async def set_auth(self, ctx: "BizHawkClientContext") -> None: + if self.rom: + ctx.auth = b64encode(self.rom).decode() + + def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: Dict[str, Any]) -> None: + if cmd == "Bounced": + if "tags" in args: + assert ctx.slot is not None + if "DeathLink" in args["tags"] and args["data"]["source"] != ctx.slot_info[ctx.slot].name: + self.on_deathlink(ctx) + elif cmd == "Retrieved": + if f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}" in args["keys"]: + self.last_wily = args["keys"][f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}"] + elif cmd == "Connected": + if self.energy_link: + ctx.set_notify(f"EnergyLink{ctx.team}") + if ctx.ui: + ctx.ui.enable_energy_link() + + async def send_deathlink(self, ctx: "BizHawkClientContext") -> None: + self.sending_death_link = True + ctx.last_death_link = time.time() + await ctx.send_death("Mega Man was defeated.") + + def on_deathlink(self, ctx: "BizHawkClientContext") -> None: + ctx.last_death_link = time.time() + self.pending_death_link = True + + async def game_watcher(self, ctx: "BizHawkClientContext") -> None: + from worlds._bizhawk import read, write + + if ctx.server is None: + return + + if ctx.slot is None: + return + + # get our relevant bytes + robot_masters_unlocked, robot_masters_defeated, items_acquired, \ + weapons_unlocked, items_unlocked, items_received, \ + completed_stages, consumable_checks, \ + e_tanks, lives, weapon_energy, health, difficulty, death_link_status, \ + energy_link_packet, last_wily = await read(ctx.bizhawk_ctx, [ + (MM2_ROBOT_MASTERS_UNLOCKED, 1, "RAM"), + (MM2_ROBOT_MASTERS_DEFEATED, 1, "RAM"), + (MM2_ITEMS_ACQUIRED, 1, "RAM"), + (MM2_WEAPONS_UNLOCKED, 1, "RAM"), + (MM2_ITEMS_UNLOCKED, 1, "RAM"), + (MM2_RECEIVED_ITEMS, 1, "RAM"), + (MM2_COMPLETED_STAGES, 0xE, "RAM"), + (MM2_CONSUMABLES, 52, "RAM"), + (MM2_E_TANKS, 1, "RAM"), + (MM2_LIVES, 1, "RAM"), + (MM2_WEAPON_ENERGY, 11, "RAM"), + (MM2_HEALTH, 1, "RAM"), + (MM2_DIFFICULTY, 1, "RAM"), + (MM2_DEATHLINK, 1, "RAM"), + (MM2_ENERGYLINK, 1, "RAM"), + (MM2_LAST_WILY, 1, "RAM"), + ]) + + if difficulty[0] not in (0, 1): + return # Game is not initialized + + if not ctx.finished_game and completed_stages[0xD] != 0: + # this sets on credits fade, no real better way to do this + await ctx.send_msgs([{ + "cmd": "StatusUpdate", + "status": ClientStatus.CLIENT_GOAL + }]) + writes = [] + + # deathlink + if self.death_link: + await ctx.update_death_link(self.death_link) + if self.pending_death_link: + writes.append((MM2_DEATHLINK, bytes([0x01]), "RAM")) + self.pending_death_link = False + self.sending_death_link = True + if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time(): + if health[0] == 0x00 and not self.sending_death_link: + await self.send_deathlink(ctx) + elif health[0] != 0x00 and not death_link_status[0]: + self.sending_death_link = False + + if self.last_wily != last_wily[0]: + if self.last_wily is None: + # revalidate last wily from data storage + await ctx.send_msgs([{"cmd": "Set", "key": f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "default", "value": 8} + ]}]) + await ctx.send_msgs([{"cmd": "Get", "keys": [f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}"]}]) + elif last_wily[0] == 0: + writes.append((MM2_LAST_WILY, self.last_wily.to_bytes(1, "little"), "RAM")) + else: + # correct our setting + self.last_wily = last_wily[0] + await ctx.send_msgs([{"cmd": "Set", "key": f"MM2_LAST_WILY_{ctx.team}_{ctx.slot}", "operations": [ + {"operation": "replace", "value": self.last_wily} + ]}]) + + # handle receiving items + recv_amount = items_received[0] + if recv_amount < len(ctx.items_received): + item = ctx.items_received[recv_amount] + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names.lookup_in_slot(item.location, item.player), recv_amount, len(ctx.items_received))) + + if item.item & 0x130 == 0: + # Robot Master Weapon + new_weapons = weapons_unlocked[0] | (1 << ((item.item & 0xF) - 1)) + writes.append((MM2_WEAPONS_UNLOCKED, new_weapons.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x21)) + elif item.item & 0x30 == 0: + # Robot Master Stage Access + new_stages = robot_masters_unlocked[0] & ~(1 << ((item.item & 0xF) - 1)) + writes.append((MM2_ROBOT_MASTERS_UNLOCKED, new_stages.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x3a)) + writes.append((MM2_RBM_STROBE, b"\x01", "RAM")) + elif item.item & 0x20 == 0: + # Items + new_items = items_unlocked[0] | (1 << ((item.item & 0xF) - 1)) + writes.append((MM2_ITEMS_UNLOCKED, new_items.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x21)) + else: + # append to the queue, so we handle it later + self.item_queue.append(item) + recv_amount += 1 + writes.append((MM2_RECEIVED_ITEMS, recv_amount.to_bytes(1, 'little'), "RAM")) + + if energy_link_packet[0]: + pickup = energy_link_packet[0] + if pickup in (0x76, 0x77): + # Health pickups + if pickup == 0x77: + value = 2 + else: + value = 10 + exchange_rate = HP_EXCHANGE_RATE + elif pickup in (0x78, 0x79): + # Weapon Energy + if pickup == 0x79: + value = 2 + else: + value = 10 + exchange_rate = WEAPON_EXCHANGE_RATE + elif pickup == 0x7B: + # 1-Up + value = 1 + exchange_rate = ONEUP_EXCHANGE_RATE + else: + # if we managed to pickup something else, we should just fall through + value = 0 + exchange_rate = 0 + contribution = (value * exchange_rate) >> 1 + if contribution: + await ctx.send_msgs([{ + "cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations": + [{"operation": "add", "value": contribution}, + {"operation": "max", "value": 0}]}]) + logger.info(f"Deposited {contribution / HP_EXCHANGE_RATE} health into the pool.") + writes.append((MM2_ENERGYLINK, 0x00.to_bytes(1, "little"), "RAM")) + + if self.weapon_energy: + # Weapon Energy + # We parse the whole thing to spread it as thin as possible + current_energy = self.weapon_energy + weapon_energy = bytearray(weapon_energy) + for i, weapon in zip(range(len(weapon_energy)), weapon_energy): + if weapon < 0x1C: + missing = 0x1C - weapon + if missing > self.weapon_energy: + missing = self.weapon_energy + self.weapon_energy -= missing + weapon_energy[i] = weapon + missing + if not self.weapon_energy: + writes.append((MM2_WEAPON_ENERGY, weapon_energy, "RAM")) + break + else: + if current_energy != self.weapon_energy: + writes.append((MM2_WEAPON_ENERGY, weapon_energy, "RAM")) + + if self.health_energy or self.auto_heal: + # Health Energy + # We save this if the player has not taken any damage + current_health = health[0] + if 0 < current_health < 0x1C: + health_diff = 0x1C - current_health + if self.health_energy: + if health_diff > self.health_energy: + health_diff = self.health_energy + self.health_energy -= health_diff + else: + pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0) + if health_diff * HP_EXCHANGE_RATE > pool: + health_diff = int(pool // HP_EXCHANGE_RATE) + await ctx.send_msgs([{ + "cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations": + [{"operation": "add", "value": -health_diff * HP_EXCHANGE_RATE}, + {"operation": "max", "value": 0}]}]) + current_health += health_diff + writes.append((MM2_HEALTH, current_health.to_bytes(1, 'little'), "RAM")) + + if self.refill_queue: + refill_type, refill_amount = self.refill_queue.pop() + if refill_type == MM2EnergyLinkType.Life: + exchange_rate = HP_EXCHANGE_RATE + elif refill_type == MM2EnergyLinkType.OneUP: + exchange_rate = ONEUP_EXCHANGE_RATE + else: + exchange_rate = WEAPON_EXCHANGE_RATE + pool = ctx.stored_data.get(f"EnergyLink{ctx.team}", 0) + request = exchange_rate * refill_amount + if request > pool: + logger.warning( + f"Not enough energy to fulfill the request. Maximum request: {pool // exchange_rate}") + else: + await ctx.send_msgs([{ + "cmd": "Set", "key": f"EnergyLink{ctx.team}", "slot": ctx.slot, "operations": + [{"operation": "add", "value": -request}, + {"operation": "max", "value": 0}]}]) + if refill_type == MM2EnergyLinkType.Life: + refill_ptr = MM2_HEALTH + elif refill_type == MM2EnergyLinkType.OneUP: + refill_ptr = MM2_LIVES + else: + refill_ptr = MM2_WEAPON_ENERGY - 1 + refill_type + current_value = (await read(ctx.bizhawk_ctx, [(refill_ptr, 1, "RAM")]))[0][0] + new_value = min(0x1C if refill_type != MM2EnergyLinkType.OneUP else 99, current_value + refill_amount) + writes.append((refill_ptr, new_value.to_bytes(1, "little"), "RAM")) + + if len(self.item_queue): + item = self.item_queue.pop(0) + idx = item.item & 0xF + if idx == 0: + # 1-Up + current_lives = lives[0] + if current_lives > 99: + self.item_queue.append(item) + else: + current_lives += 1 + writes.append((MM2_LIVES, current_lives.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x42)) + elif idx == 1: + self.weapon_energy += 0xE + writes.extend(get_sfx_writes(0x28)) + elif idx == 2: + self.health_energy += 0xE + writes.extend(get_sfx_writes(0x28)) + elif idx == 3: + # E-Tank + # visuals only allow 4, but we're gonna go up to 9 anyway? May change + current_tanks = e_tanks[0] + if current_tanks < 9: + current_tanks += 1 + writes.append((MM2_E_TANKS, current_tanks.to_bytes(1, 'little'), "RAM")) + writes.extend(get_sfx_writes(0x42)) + else: + self.item_queue.append(item) + + await write(ctx.bizhawk_ctx, writes) + + new_checks = [] + # check for locations + for i in range(8): + flag = 1 << i + if robot_masters_defeated[0] & flag: + wep_id = 0x880101 + i + if wep_id not in ctx.checked_locations: + new_checks.append(wep_id) + + for i in range(3): + flag = 1 << i + if items_acquired[0] & flag: + itm_id = 0x880111 + i + if itm_id not in ctx.checked_locations: + new_checks.append(itm_id) + + for i in range(0xD): + rbm_id = 0x880001 + i + if completed_stages[i] != 0: + if rbm_id not in ctx.checked_locations: + new_checks.append(rbm_id) + + for consumable in MM2_CONSUMABLE_TABLE: + if consumable not in ctx.checked_locations: + is_checked = consumable_checks[MM2_CONSUMABLE_TABLE[consumable][0]] \ + & MM2_CONSUMABLE_TABLE[consumable][1] + if is_checked: + new_checks.append(consumable) + + for new_check_id in new_checks: + ctx.locations_checked.add(new_check_id) + location = ctx.location_names.lookup_in_game(new_check_id) + nes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/' + f'{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}]) diff --git a/worlds/mm2/color.py b/worlds/mm2/color.py new file mode 100644 index 000000000000..77b39caf3d4f --- /dev/null +++ b/worlds/mm2/color.py @@ -0,0 +1,276 @@ +from typing import Dict, Tuple, List, TYPE_CHECKING, Union +from . import names +from zlib import crc32 +import struct +import logging + +if TYPE_CHECKING: + from . import MM2World + from .rom import MM2ProcedurePatch + +HTML_TO_NES: Dict[str, int] = { + "SNOW": 0x20, + "LINEN": 0x36, + "SEASHELL": 0x36, + "AZURE": 0x3C, + "LAVENDER": 0x33, + "WHITE": 0x30, + "BLACK": 0x0F, + "GREY": 0x00, + "GRAY": 0x00, + "ROYALBLUE": 0x12, + "BLUE": 0x11, + "SKYBLUE": 0x21, + "LIGHTBLUE": 0x31, + "TURQUOISE": 0x2B, + "CYAN": 0x2C, + "AQUAMARINE": 0x3B, + "DARKGREEN": 0x0A, + "GREEN": 0x1A, + "YELLOW": 0x28, + "GOLD": 0x28, + "WHEAT": 0x37, + "TAN": 0x37, + "CHOCOLATE": 0x07, + "BROWN": 0x07, + "SALMON": 0x26, + "ORANGE": 0x27, + "CORAL": 0x36, + "TOMATO": 0x16, + "RED": 0x16, + "PINK": 0x25, + "MAROON": 0x06, + "MAGENTA": 0x24, + "FUSCHIA": 0x24, + "VIOLET": 0x24, + "PLUM": 0x33, + "PURPLE": 0x14, + "THISTLE": 0x34, + "DARKBLUE": 0x01, + "SILVER": 0x10, + "NAVY": 0x02, + "TEAL": 0x1C, + "OLIVE": 0x18, + "LIME": 0x2A, + "AQUA": 0x2C, + # can add more as needed +} + +MM2_COLORS: Dict[str, Tuple[int, int]] = { + names.atomic_fire: (0x28, 0x15), + names.air_shooter: (0x20, 0x11), + names.leaf_shield: (0x20, 0x19), + names.bubble_lead: (0x20, 0x00), + names.time_stopper: (0x34, 0x25), + names.quick_boomerang: (0x34, 0x14), + names.metal_blade: (0x37, 0x18), + names.crash_bomber: (0x20, 0x26), + names.item_1: (0x20, 0x16), + names.item_2: (0x20, 0x16), + names.item_3: (0x20, 0x16), + names.heat_man_stage: (0x28, 0x15), + names.air_man_stage: (0x28, 0x11), + names.wood_man_stage: (0x36, 0x17), + names.bubble_man_stage: (0x30, 0x19), + names.quick_man_stage: (0x28, 0x15), + names.flash_man_stage: (0x30, 0x12), + names.metal_man_stage: (0x28, 0x15), + names.crash_man_stage: (0x30, 0x16) +} + +MM2_KNOWN_COLORS: Dict[str, Tuple[int, int]] = { + **MM2_COLORS, + # Street Fighter, technically + "Hadouken": (0x3C, 0x11), + "Shoryuken": (0x38, 0x16), + # X Series + "Z-Saber": (0x20, 0x16), + # X1 + "Homing Torpedo": (0x3D, 0x37), + "Chameleon Sting": (0x3B, 0x1A), + "Rolling Shield": (0x3A, 0x25), + "Fire Wave": (0x37, 0x26), + "Storm Tornado": (0x34, 0x14), + "Electric Spark": (0x3D, 0x28), + "Boomerang Cutter": (0x3B, 0x2D), + "Shotgun Ice": (0x28, 0x2C), + # X2 + "Crystal Hunter": (0x33, 0x21), + "Bubble Splash": (0x35, 0x28), + "Spin Wheel": (0x34, 0x1B), + "Silk Shot": (0x3B, 0x27), + "Sonic Slicer": (0x27, 0x01), + "Strike Chain": (0x30, 0x23), + "Magnet Mine": (0x28, 0x2D), + "Speed Burner": (0x31, 0x16), + # X3 + "Acid Burst": (0x28, 0x2A), + "Tornado Fang": (0x28, 0x2C), + "Triad Thunder": (0x2B, 0x23), + "Spinning Blade": (0x20, 0x16), + "Ray Splasher": (0x28, 0x17), + "Gravity Well": (0x38, 0x14), + "Parasitic Bomb": (0x31, 0x28), + "Frost Shield": (0x23, 0x2C), +} + +palette_pointers: Dict[str, List[int]] = { + "Mega Buster": [0x3D314], + "Atomic Fire": [0x3D318], + "Air Shooter": [0x3D31C], + "Leaf Shield": [0x3D320], + "Bubble Lead": [0x3D324], + "Quick Boomerang": [0x3D328], + "Time Stopper": [0x3D32C], + "Metal Blade": [0x3D330], + "Crash Bomber": [0x3D334], + "Item 1": [0x3D338], + "Item 2": [0x3D33C], + "Item 3": [0x3D340], + "Heat Man": [0x34B6, 0x344F7], + "Air Man": [0x74B6, 0x344FF], + "Wood Man": [0xB4EC, 0x34507], + "Bubble Man": [0xF4B6, 0x3450F], + "Quick Man": [0x134C8, 0x34517], + "Flash Man": [0x174B6, 0x3451F], + "Metal Man": [0x1B4A4, 0x34527], + "Crash Man": [0x1F4EC, 0x3452F], +} + + +def add_color_to_mm2(name: str, color: Tuple[int, int]) -> None: + """ + Add a color combo for Mega Man 2 to recognize as the color to display for a given item. + For information on available colors: https://www.nesdev.org/wiki/PPU_palettes#2C02 + """ + MM2_KNOWN_COLORS[name] = validate_colors(*color) + + +def extrapolate_color(color: int) -> Tuple[int, int]: + if color > 0x1F: + color_1 = color + color_2 = color_1 - 0x10 + else: + color_2 = color + color_1 = color_2 + 0x10 + return color_1, color_2 + + +def validate_colors(color_1: int, color_2: int, allow_match: bool = False) -> Tuple[int, int]: + # Black should be reserved for outlines, a gray should suffice + if color_1 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]: + color_1 = 0x10 + if color_2 in [0x0D, 0x0E, 0x0F, 0x1E, 0x2E, 0x3E, 0x1F, 0x2F, 0x3F]: + color_2 = 0x10 + + # one final check, make sure we don't have two matching + if not allow_match and color_1 == color_2: + color_1 = 0x30 # color 1 to white works with about any paired color + + return color_1, color_2 + + +def get_colors_for_item(name: str) -> Tuple[int, int]: + if name in MM2_KNOWN_COLORS: + return MM2_KNOWN_COLORS[name] + + check_colors = {color: color in name.upper().replace(" ", "") for color in HTML_TO_NES} + colors = [color for color in check_colors if check_colors[color]] + if colors: + # we have at least one color pattern matched + if len(colors) > 1: + # we have at least 2 + color_1 = HTML_TO_NES[colors[0]] + color_2 = HTML_TO_NES[colors[1]] + else: + color_1, color_2 = extrapolate_color(HTML_TO_NES[colors[0]]) + else: + # generate hash + crc_hash = crc32(name.encode("utf-8")) + hash_color = struct.pack("I", crc_hash) + color_1 = hash_color[0] % 0x3F + color_2 = hash_color[1] % 0x3F + + if color_1 < color_2: + temp = color_1 + color_1 = color_2 + color_2 = temp + + color_1, color_2 = validate_colors(color_1, color_2) + + return color_1, color_2 + + +def parse_color(colors: List[str]) -> Tuple[int, int]: + color_a = colors[0] + if color_a.startswith("$"): + color_1 = int(color_a[1:], 16) + else: + # assume it's in our list of colors + color_1 = HTML_TO_NES[color_a.upper()] + + if len(colors) == 1: + color_1, color_2 = extrapolate_color(color_1) + else: + color_b = colors[1] + if color_b.startswith("$"): + color_2 = int(color_b[1:], 16) + else: + color_2 = HTML_TO_NES[color_b.upper()] + return color_1, color_2 + + +def write_palette_shuffle(world: "MM2World", rom: "MM2ProcedurePatch") -> None: + palette_shuffle: Union[int, str] = world.options.palette_shuffle.value + palettes_to_write: Dict[str, Tuple[int, int]] = {} + if isinstance(palette_shuffle, str): + color_sets = palette_shuffle.split(";") + if len(color_sets) == 1: + palette_shuffle = world.options.palette_shuffle.option_none + # singularity is more correct, but this is faster + else: + palette_shuffle = world.options.palette_shuffle.options[color_sets.pop()] + for color_set in color_sets: + if "-" in color_set: + character, color = color_set.split("-") + if character.title() not in palette_pointers: + logging.warning(f"Player {world.multiworld.get_player_name(world.player)} " + f"attempted to set color for unrecognized option {character}") + colors = color.split("|") + real_colors = validate_colors(*parse_color(colors), allow_match=True) + palettes_to_write[character.title()] = real_colors + else: + # If color is provided with no character, assume singularity + colors = color_set.split("|") + real_colors = validate_colors(*parse_color(colors), allow_match=True) + for character in palette_pointers: + palettes_to_write[character] = real_colors + # Now we handle the real values + if palette_shuffle == 1: + shuffled_colors = list(MM2_COLORS.values()) + shuffled_colors.append((0x2C, 0x11)) # Mega Buster + world.random.shuffle(shuffled_colors) + for character in palette_pointers: + if character not in palettes_to_write: + palettes_to_write[character] = shuffled_colors.pop() + elif palette_shuffle > 1: + if palette_shuffle == 2: + for character in palette_pointers: + if character not in palettes_to_write: + real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F)) + palettes_to_write[character] = real_colors + else: + # singularity + real_colors = validate_colors(world.random.randint(0, 0x3F), world.random.randint(0, 0x3F)) + for character in palette_pointers: + if character not in palettes_to_write: + palettes_to_write[character] = real_colors + + for character in palettes_to_write: + for pointer in palette_pointers[character]: + rom.write_bytes(pointer, bytes(palettes_to_write[character])) + + if character == "Atomic Fire": + # special case, we need to update Atomic Fire's flashing routine + rom.write_byte(0x3DE4A, palettes_to_write[character][1]) + rom.write_byte(0x3DE4C, palettes_to_write[character][1]) diff --git a/worlds/mm2/data/mm2_basepatch.bsdiff4 b/worlds/mm2/data/mm2_basepatch.bsdiff4 new file mode 100644 index 0000000000000000000000000000000000000000..8f3c17c3c7af32fa06906208f5d94058d7c2cea8 GIT binary patch literal 1440 zcmV;R1z-9?Q$$HdMl>*`000000002o0ssI20000G00aO40000&T4*&fL0KkKS)8}E z3;+Om|L=OdD*yllz6?MBK!6G(Mg~D3KoS9vKmZ^B01zMmXlJAV8UQ^bMi2l7jVY#) zqd+tO$TU4BpQ;cdD`{y?Fv>?~gxj9sB*IH8D%Z)S6=j+uLNNdU0ACeh7$Ql#umfZY z04`uef}&|Jd;=~D2!RhNy)FM*Gd&e6xt=+c6j9phF~ta3&+&I8Q-ui$%X?73LRx4w zF+o`-Q(58#Oo#vi9e@A-_y65}LmuD%cmD*(6|tT9kyIU7L=aU~f8F2zXa9e|0nQxf zHN{Dk(diFUBPNXongQxG007fNAO?U2o`e`6$Z6<+G7U5anjWXArh_A8G%BX_r=m1s zG8!5h223E)pk&C<0s4@{(;&zV42=QmG-L*hnrP9W85#^sffGrRnE~nzJw|{H0002c zG#UT^000000LTCU0MkHvK_sY#hNc=5O$>tp4HyJ8$)-a9AZTI@4KXlG5rHx?WXNd5 zG{G`p$qTLnqPUy2X~4|l6wsceav~I}gak#=s)%TiKpB#*8L`1Oni1T=&_SU^O8pf+ z;vvZ&6(`t!)zE=Ewe-$uSmkj#E>OR$leRz12^*}CxXgq+VI0O10Ec9P4S)d9U=W_L zAbh|jyuv^Wj_JfQ5USV&`H}>HAsHbglGtR0&E>+m1UO`c#dzV<4+cOal6C91fu?^= zm#Pq~$_h^u7(*AxK#(aG3_z43LNXa~1}TvA%+G3W5OzmD#Q$XhlCt!v!bsu;A2$`KQ z>_6lX8J)j)$`2`DcEYhK7>^%k{Z78}kXqdyB>y7U^}Fwvp3=p9pYK=YezwdM+j}AtK6U#v%Z@&43bO1wKK=m5{@Ka=cYA*dhYC zK;r~*D}UIA9U;qFA_rQ|QuzW#oOz{E0Y(WM`THtz9en1Cca6kx4XkWsx z0dozIY9NRUy42O&I_h z02%-TKpF$o(9jJ5B%)0+JwN~r0D6D`WNDz&O&S>hH1z-g85cy2`nh~04FJU8awD+% zije{AL%=Sx5*))pr0xy&ft3bSLkm9ZmtnSCa>C@$a95)gEt9ZV^MpQ`u8F&wB>Kv2 z&j}+=rE`e?e~ihp8wJ3Ce-4zf5)w5MHi;<_3W+3VZ5f;6yE24$u^AvMhn72?Q{hE| zq!K0qLjp$NBuxa2xE^@voCyp<-ALgPEx6DHYp#Ms@JMkxM#K^!q)blOhCveoN}7?MFL zSn^d ` Only present with EnergyLink, sends a request of a certain type of energy to be pulled from +the EnergyLink. Types are as follows: + - `HP` Health + - `AF` Atomic Fire + - `AS` Air Shooter + - `LS` Leaf Shield + - `BL` Bubble Lead + - `QB` Quick Boomerang + - `TS` Time Stopper + - `MB` Metal Blade + - `CB` Crash Bomber + - `I1` Item 1 + - `I2` Item 2 + - `I3` Item 3 + - `1U` Lives \ No newline at end of file diff --git a/worlds/mm2/docs/setup_en.md b/worlds/mm2/docs/setup_en.md new file mode 100644 index 000000000000..3b8f833b9967 --- /dev/null +++ b/worlds/mm2/docs/setup_en.md @@ -0,0 +1,53 @@ +# Mega Man 2 Setup Guide + +## Required Software + +- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) +- An English Mega Man 2 ROM. Alternatively, the [Mega Man Legacy Collection](https://store.steampowered.com/app/363440/Mega_Man_Legacy_Collection/) on Steam. +- [BizHawk](https://tasvideos.org/BizHawk/ReleaseHistory) 2.7 or later + +### Configuring Bizhawk + +Once you have installed BizHawk, open `EmuHawk.exe` and change the following settings: + +- If you're using BizHawk 2.7 or 2.8, go to `Config > Customize`. On the Advanced tab, switch the Lua Core from +`NLua+KopiLua` to `Lua+LuaInterface`, then restart EmuHawk. (If you're using BizHawk 2.9, you can skip this step.) +- Under `Config > Customize`, check the "Run in background" option to prevent disconnecting from the client while you're +tabbed out of EmuHawk. +- Open a `.nes` file in EmuHawk and go to `Config > Controllers…` to configure your inputs. If you can't click +`Controllers…`, load any `.nes` ROM first. +- Consider clearing keybinds in `Config > Hotkeys…` if you don't intend to use them. Select the keybind and press Esc to +clear it. + +## Generating and Patching a Game + +1. Create your options file (YAML). You can make one on the +[Mega Man 2 options page](../../../games/Mega%20Man%202/player-options). +2. Follow the general Archipelago instructions for [generating a game](../../Archipelago/setup/en#generating-a-game). +This will generate an output file for you. Your patch file will have the `.apmm2` file extension. +3. Open `ArchipelagoLauncher.exe` +4. Select "Open Patch" on the left side and select your patch file. +5. If this is your first time patching, you will be prompted to locate your vanilla ROM. If you are using the Legacy +Collection, provide `Proteus.exe` in place of your rom. +6. A patched `.nes` file will be created in the same place as the patch file. +7. On your first time opening a patch with BizHawk Client, you will also be asked to locate `EmuHawk.exe` in your +BizHawk install. + +## Connecting to a Server + +By default, opening a patch file will do steps 1-5 below for you automatically. Even so, keep them in your memory just +in case you have to close and reopen a window mid-game for some reason. + +1. Mega Man 2 uses Archipelago's BizHawk Client. If the client isn't still open from when you patched your game, +you can re-open it from the launcher. +2. Ensure EmuHawk is running the patched ROM. +3. In EmuHawk, go to `Tools > Lua Console`. This window must stay open while playing. +4. In the Lua Console window, go to `Script > Open Script…`. +5. Navigate to your Archipelago install folder and open `data/lua/connector_bizhawk_generic.lua`. +6. The emulator and client will eventually connect to each other. The BizHawk Client window should indicate that it +connected and recognized Mega Man 2. +7. To connect the client to the server, enter your room's address and port (e.g. `archipelago.gg:38281`) into the +top text field of the client and click Connect. + +You should now be able to receive and send items. You'll need to do these steps every time you want to reconnect. It is +perfectly safe to make progress offline; everything will re-sync when you reconnect. diff --git a/worlds/mm2/items.py b/worlds/mm2/items.py new file mode 100644 index 000000000000..e644b171dded --- /dev/null +++ b/worlds/mm2/items.py @@ -0,0 +1,72 @@ +from BaseClasses import Item +from typing import NamedTuple, Dict +from . import names + + +class ItemData(NamedTuple): + code: int + progression: bool + useful: bool = False # primarily use this for incredibly useful items of their class, like Metal Blade + skip_balancing: bool = False + + +class MM2Item(Item): + game = "Mega Man 2" + + +robot_master_weapon_table = { + names.atomic_fire: ItemData(0x880001, True), + names.air_shooter: ItemData(0x880002, True), + names.leaf_shield: ItemData(0x880003, True), + names.bubble_lead: ItemData(0x880004, True), + names.quick_boomerang: ItemData(0x880005, True), + names.time_stopper: ItemData(0x880006, True, True), + names.metal_blade: ItemData(0x880007, True, True), + names.crash_bomber: ItemData(0x880008, True), +} + +stage_access_table = { + names.heat_man_stage: ItemData(0x880101, True), + names.air_man_stage: ItemData(0x880102, True), + names.wood_man_stage: ItemData(0x880103, True), + names.bubble_man_stage: ItemData(0x880104, True), + names.quick_man_stage: ItemData(0x880105, True), + names.flash_man_stage: ItemData(0x880106, True), + names.metal_man_stage: ItemData(0x880107, True), + names.crash_man_stage: ItemData(0x880108, True), +} + +item_item_table = { + names.item_1: ItemData(0x880011, True, True, True), + names.item_2: ItemData(0x880012, True, True, True), + names.item_3: ItemData(0x880013, True, True, True) +} + +filler_item_table = { + names.one_up: ItemData(0x880020, False), + names.weapon_energy: ItemData(0x880021, False), + names.health_energy: ItemData(0x880022, False), + names.e_tank: ItemData(0x880023, False, True), +} + +filler_item_weights = { + names.one_up: 1, + names.weapon_energy: 4, + names.health_energy: 1, + names.e_tank: 2, +} + +item_table = { + **robot_master_weapon_table, + **stage_access_table, + **item_item_table, + **filler_item_table, +} + +item_names = { + "Weapons": {name for name in robot_master_weapon_table.keys()}, + "Stages": {name for name in stage_access_table.keys()}, + "Items": {name for name in item_item_table.keys()} +} + +lookup_item_to_id: Dict[str, int] = {item_name: data.code for item_name, data in item_table.items()} diff --git a/worlds/mm2/locations.py b/worlds/mm2/locations.py new file mode 100644 index 000000000000..4807d25d6992 --- /dev/null +++ b/worlds/mm2/locations.py @@ -0,0 +1,239 @@ +from BaseClasses import Location, Region +from typing import Dict, Tuple, Optional +from . import names + + +class MM2Location(Location): + game = "Mega Man 2" + + +class MM2Region(Region): + game = "Mega Man 2" + + +heat_man_locations: Dict[str, Optional[int]] = { + names.heat_man: 0x880001, + names.atomic_fire_get: 0x880101, + names.item_1_get: 0x880111, +} + +air_man_locations: Dict[str, Optional[int]] = { + names.air_man: 0x880002, + names.air_shooter_get: 0x880102, + names.item_2_get: 0x880112 +} + +wood_man_locations: Dict[str, Optional[int]] = { + names.wood_man: 0x880003, + names.leaf_shield_get: 0x880103 +} + +bubble_man_locations: Dict[str, Optional[int]] = { + names.bubble_man: 0x880004, + names.bubble_lead_get: 0x880104 +} + +quick_man_locations: Dict[str, Optional[int]] = { + names.quick_man: 0x880005, + names.quick_boomerang_get: 0x880105, +} + +flash_man_locations: Dict[str, Optional[int]] = { + names.flash_man: 0x880006, + names.time_stopper_get: 0x880106, + names.item_3_get: 0x880113, +} + +metal_man_locations: Dict[str, Optional[int]] = { + names.metal_man: 0x880007, + names.metal_blade_get: 0x880107 +} + +crash_man_locations: Dict[str, Optional[int]] = { + names.crash_man: 0x880008, + names.crash_bomber_get: 0x880108 +} + +wily_1_locations: Dict[str, Optional[int]] = { + names.wily_1: 0x880009, + names.wily_stage_1: None +} + +wily_2_locations: Dict[str, Optional[int]] = { + names.wily_2: 0x88000A, + names.wily_stage_2: None +} + +wily_3_locations: Dict[str, Optional[int]] = { + names.wily_3: 0x88000B, + names.wily_stage_3: None +} + +wily_4_locations: Dict[str, Optional[int]] = { + names.wily_4: 0x88000C, + names.wily_stage_4: None +} + +wily_5_locations: Dict[str, Optional[int]] = { + names.wily_5: 0x88000D, + names.wily_stage_5: None +} + +wily_6_locations: Dict[str, Optional[int]] = { + names.dr_wily: None +} + +etank_1ups: Dict[str, Dict[str, Optional[int]]] = { + "Heat Man Stage": { + names.heat_man_c1: 0x880201, + }, + "Quick Man Stage": { + names.quick_man_c1: 0x880202, + names.quick_man_c2: 0x880203, + names.quick_man_c3: 0x880204, + names.quick_man_c7: 0x880208, + }, + "Flash Man Stage": { + names.flash_man_c2: 0x88020B, + names.flash_man_c6: 0x88020F, + }, + "Metal Man Stage": { + names.metal_man_c1: 0x880210, + names.metal_man_c2: 0x880211, + names.metal_man_c3: 0x880212, + }, + "Crash Man Stage": { + names.crash_man_c2: 0x880214, + names.crash_man_c3: 0x880215, + }, + "Wily Stage 1": { + names.wily_1_c1: 0x880216, + }, + "Wily Stage 2": { + names.wily_2_c3: 0x88021A, + names.wily_2_c4: 0x88021B, + names.wily_2_c5: 0x88021C, + names.wily_2_c6: 0x88021D, + }, + "Wily Stage 3": { + names.wily_3_c2: 0x880220, + }, + "Wily Stage 4": { + names.wily_4_c3: 0x880225, + names.wily_4_c4: 0x880226, + } +} + +energy_pickups: Dict[str, Dict[str, Optional[int]]] = { + "Quick Man Stage": { + names.quick_man_c4: 0x880205, + names.quick_man_c5: 0x880206, + names.quick_man_c6: 0x880207, + names.quick_man_c8: 0x880209, + }, + "Flash Man Stage": { + names.flash_man_c1: 0x88020A, + names.flash_man_c3: 0x88020C, + names.flash_man_c4: 0x88020D, + names.flash_man_c5: 0x88020E, + }, + "Crash Man Stage": { + names.crash_man_c1: 0x880213, + }, + "Wily Stage 1": { + names.wily_1_c2: 0x880217, + }, + "Wily Stage 2": { + names.wily_2_c1: 0x880218, + names.wily_2_c2: 0x880219, + names.wily_2_c7: 0x88021E, + names.wily_2_c8: 0x880227, + names.wily_2_c9: 0x880228, + names.wily_2_c10: 0x880229, + names.wily_2_c11: 0x88022A, + names.wily_2_c12: 0x88022B, + names.wily_2_c13: 0x88022C, + names.wily_2_c14: 0x88022D, + names.wily_2_c15: 0x88022E, + names.wily_2_c16: 0x88022F, + }, + "Wily Stage 3": { + names.wily_3_c1: 0x88021F, + names.wily_3_c3: 0x880221, + names.wily_3_c4: 0x880222, + }, + "Wily Stage 4": { + names.wily_4_c1: 0x880223, + names.wily_4_c2: 0x880224, + } +} + +mm2_regions: Dict[str, Tuple[Tuple[str, ...], Dict[str, Optional[int]], Optional[str]]] = { + "Heat Man Stage": ((names.heat_man_stage,), heat_man_locations, None), + "Air Man Stage": ((names.air_man_stage,), air_man_locations, None), + "Wood Man Stage": ((names.wood_man_stage,), wood_man_locations, None), + "Bubble Man Stage": ((names.bubble_man_stage,), bubble_man_locations, None), + "Quick Man Stage": ((names.quick_man_stage,), quick_man_locations, None), + "Flash Man Stage": ((names.flash_man_stage,), flash_man_locations, None), + "Metal Man Stage": ((names.metal_man_stage,), metal_man_locations, None), + "Crash Man Stage": ((names.crash_man_stage,), crash_man_locations, None), + "Wily Stage 1": ((names.item_1, names.item_2, names.item_3), wily_1_locations, None), + "Wily Stage 2": ((names.wily_stage_1,), wily_2_locations, "Wily Stage 1"), + "Wily Stage 3": ((names.wily_stage_2,), wily_3_locations, "Wily Stage 2"), + "Wily Stage 4": ((names.wily_stage_3,), wily_4_locations, "Wily Stage 3"), + "Wily Stage 5": ((names.wily_stage_4,), wily_5_locations, "Wily Stage 4"), + "Wily Stage 6": ((names.wily_stage_5,), wily_6_locations, "Wily Stage 5") +} + +location_table: Dict[str, Optional[int]] = { + **heat_man_locations, + **air_man_locations, + **wood_man_locations, + **bubble_man_locations, + **quick_man_locations, + **flash_man_locations, + **metal_man_locations, + **crash_man_locations, + **wily_1_locations, + **wily_2_locations, + **wily_3_locations, + **wily_4_locations, + **wily_5_locations, +} + +for table in etank_1ups: + location_table.update(etank_1ups[table]) + +for table in energy_pickups: + location_table.update(energy_pickups[table]) + +location_groups = { + "Get Equipped": { + names.atomic_fire_get, + names.air_shooter_get, + names.leaf_shield_get, + names.bubble_lead_get, + names.quick_boomerang_get, + names.time_stopper_get, + names.metal_blade_get, + names.crash_bomber_get, + names.item_1_get, + names.item_2_get, + names.item_3_get + }, + "Heat Man Stage": {*heat_man_locations.keys(), *etank_1ups["Heat Man Stage"].keys()}, + "Air Man Stage": {*air_man_locations.keys()}, + "Wood Man Stage": {*wood_man_locations.keys()}, + "Bubble Man Stage": {*bubble_man_locations.keys()}, + "Quick Man Stage": {*quick_man_locations.keys(), *etank_1ups["Quick Man Stage"].keys(), + *energy_pickups["Quick Man Stage"].keys()}, + "Flash Man Stage": {*flash_man_locations.keys(), *etank_1ups["Flash Man Stage"].keys(), + *energy_pickups["Flash Man Stage"].keys()}, + "Metal Man Stage": {*metal_man_locations.keys(), *etank_1ups["Metal Man Stage"].keys()}, + "Crash Man Stage": {*crash_man_locations.keys(), *etank_1ups["Crash Man Stage"].keys(), + *energy_pickups["Crash Man Stage"].keys()}, + "Wily 2 Weapon Energy": {names.wily_2_c8, names.wily_2_c9, names.wily_2_c10, names.wily_2_c11, names.wily_2_c12, + names.wily_2_c13, names.wily_2_c14, names.wily_2_c15, names.wily_2_c16} +} + +lookup_location_to_id: Dict[str, int] = {location: idx for location, idx in location_table.items() if idx is not None} diff --git a/worlds/mm2/names.py b/worlds/mm2/names.py new file mode 100644 index 000000000000..fbbea85f0317 --- /dev/null +++ b/worlds/mm2/names.py @@ -0,0 +1,114 @@ +# Robot Master Weapons +crash_bomber = "Crash Bomber" +metal_blade = "Metal Blade" +quick_boomerang = "Quick Boomerang" +bubble_lead = "Bubble Lead" +atomic_fire = "Atomic Fire" +leaf_shield = "Leaf Shield" +time_stopper = "Time Stopper" +air_shooter = "Air Shooter" + +# Stage Entry +crash_man_stage = "Crash Man Access Codes" +metal_man_stage = "Metal Man Access Codes" +quick_man_stage = "Quick Man Access Codes" +bubble_man_stage = "Bubble Man Access Codes" +heat_man_stage = "Heat Man Access Codes" +wood_man_stage = "Wood Man Access Codes" +flash_man_stage = "Flash Man Access Codes" +air_man_stage = "Air Man Access Codes" + +# The Items +item_1 = "Item 1 - Propeller" +item_2 = "Item 2 - Rocket" +item_3 = "Item 3 - Bouncy" + +# Misc. Items +one_up = "1-Up" +weapon_energy = "Weapon Energy (L)" +health_energy = "Health Energy (L)" +e_tank = "E-Tank" + +# Locations +crash_man = "Crash Man - Defeated" +metal_man = "Metal Man - Defeated" +quick_man = "Quick Man - Defeated" +bubble_man = "Bubble Man - Defeated" +heat_man = "Heat Man - Defeated" +wood_man = "Wood Man - Defeated" +flash_man = "Flash Man - Defeated" +air_man = "Air Man - Defeated" +crash_bomber_get = "Crash Bomber - Received" +metal_blade_get = "Metal Blade - Received" +quick_boomerang_get = "Quick Boomerang - Received" +bubble_lead_get = "Bubble Lead - Received" +atomic_fire_get = "Atomic Fire - Received" +leaf_shield_get = "Leaf Shield - Received" +time_stopper_get = "Time Stopper - Received" +air_shooter_get = "Air Shooter - Received" +item_1_get = "Item 1 - Received" +item_2_get = "Item 2 - Received" +item_3_get = "Item 3 - Received" +wily_1 = "Mecha Dragon - Defeated" +wily_2 = "Picopico-kun - Defeated" +wily_3 = "Guts Tank - Defeated" +wily_4 = "Boobeam Trap - Defeated" +wily_5 = "Wily Machine 2 - Defeated" +dr_wily = "Dr. Wily (Alien) - Defeated" + +# Wily Stage Event Items +wily_stage_1 = "Wily Stage 1 - Completed" +wily_stage_2 = "Wily Stage 2 - Completed" +wily_stage_3 = "Wily Stage 3 - Completed" +wily_stage_4 = "Wily Stage 4 - Completed" +wily_stage_5 = "Wily Stage 5 - Completed" + +# Consumable Locations +heat_man_c1 = "Heat Man Stage - 1-Up" # 3, requires Yoku jumps or Item 2 +flash_man_c1 = "Flash Man Stage - Health Energy 1" # 0 +flash_man_c2 = "Flash Man Stage - 1-Up" # 2, requires any Item +flash_man_c3 = "Flash Man Stage - Health Energy 2" # 6, requires Crash Bomber +flash_man_c4 = "Flash Man Stage - Weapon Energy 1" # 8, requires Crash Bomber +flash_man_c5 = "Flash Man Stage - Health Energy 3" # 9 +flash_man_c6 = "Flash Man Stage - E-Tank" # 10 +quick_man_c1 = "Quick Man Stage - 1-Up 1" # 0, needs any Item +quick_man_c2 = "Quick Man Stage - E-Tank" # 1, requires allow lasers or Time Stopper +quick_man_c3 = "Quick Man Stage - 1-Up 2" # 2, requires allow lasers or Time Stopper +quick_man_c4 = "Quick Man Stage - Weapon Energy 1" # 3, requires allow lasers or Time Stopper +quick_man_c5 = "Quick Man Stage - Weapon Energy 2" # 4, requires allow lasers or Time Stopper +quick_man_c6 = "Quick Man Stage - Health Energy" # 5, requires allow lasers or Time Stopper +quick_man_c7 = "Quick Man Stage - 1-Up 3" # 6, requires allow lasers or Time Stopper +quick_man_c8 = "Quick Man Stage - Weapon Energy 3" # 7, requires allow lasers or Time Stopper +metal_man_c1 = "Metal Man Stage - E-Tank 1" # 0 +metal_man_c2 = "Metal Man Stage - 1-Up" # 1, needs Item 1/2 +metal_man_c3 = "Metal Man Stage - E-Tank 2" # 2, needs Item 1/2 (without putting dying in logic at least) +crash_man_c1 = "Crash Man Stage - Health Energy" # 0 +crash_man_c2 = "Crash Man Stage - E-Tank" # 1 +crash_man_c3 = "Crash Man Stage - 1-Up" # 2, any Item +wily_1_c1 = "Wily Stage 1 - 1-Up" # 10 +wily_1_c2 = "Wily Stage 1 - Weapon Energy 1" # 11 +wily_2_c1 = "Wily Stage 2 - Weapon Energy 1" # 11 +wily_2_c2 = "Wily Stage 2 - Weapon Energy 2" # 12 +wily_2_c3 = "Wily Stage 2 - E-Tank 1" # 16 +wily_2_c4 = "Wily Stage 2 - 1-Up 1" # 17 +# 18 - 27 are all small weapon energies, might force these local junk? +wily_2_c8 = "Wily Stage 2 - Weapon Energy 3" # 18 +wily_2_c9 = "Wily Stage 2 - Weapon Energy 4" # 19 +wily_2_c10 = "Wily Stage 2 - Weapon Energy 5" # 20 +wily_2_c11 = "Wily Stage 2 - Weapon Energy 6" # 21 +wily_2_c12 = "Wily Stage 2 - Weapon Energy 7" # 22 +wily_2_c13 = "Wily Stage 2 - Weapon Energy 8" # 23 +wily_2_c14 = "Wily Stage 2 - Weapon Energy 9" # 24 +wily_2_c15 = "Wily Stage 2 - Weapon Energy 10" # 25 +wily_2_c16 = "Wily Stage 2 - Weapon Energy 11" # 26 +wily_2_c5 = "Wily Stage 2 - 1-Up 2" # 29, requires Crash Bomber +wily_2_c6 = "Wily Stage 2 - E-Tank 2" # 30, requires Crash Bomber +wily_2_c7 = "Wily Stage 2 - Health Energy" # 31, item 2 (already required to reach wily 2) +wily_3_c1 = "Wily Stage 3 - Weapon Energy 1" # 12, requires Crash Bomber +wily_3_c2 = "Wily Stage 3 - E-Tank" # 17, requires Crash Bomber +wily_3_c3 = "Wily Stage 3 - Weapon Energy 2" # 18 +wily_3_c4 = "Wily Stage 3 - Weapon Energy 3" # 19 +wily_4_c1 = "Wily Stage 4 - Weapon Energy 1" # 16 +wily_4_c2 = "Wily Stage 4 - Weapon Energy 2" # 17 +wily_4_c3 = "Wily Stage 4 - 1-Up 1" # 18 +wily_4_c4 = "Wily Stage 4 - E-Tank 1" # 19 diff --git a/worlds/mm2/options.py b/worlds/mm2/options.py new file mode 100644 index 000000000000..2d90395cacda --- /dev/null +++ b/worlds/mm2/options.py @@ -0,0 +1,229 @@ +from dataclasses import dataclass + +from Options import Choice, Toggle, DeathLink, DefaultOnToggle, TextChoice, Range, OptionDict, PerGameCommonOptions +from schema import Schema, And, Use, Optional + +bosses = { + "Heat Man": 0, + "Air Man": 1, + "Wood Man": 2, + "Bubble Man": 3, + "Quick Man": 4, + "Flash Man": 5, + "Metal Man": 6, + "Crash Man": 7, + "Mecha Dragon": 8, + "Picopico-kun": 9, + "Guts Tank": 10, + "Boobeam Trap": 11, + "Wily Machine 2": 12, + "Alien": 13 +} + +weapons_to_id = { + "Mega Buster": 0, + "Atomic Fire": 1, + "Air Shooter": 2, + "Leaf Shield": 3, + "Bubble Lead": 4, + "Quick Boomerang": 5, + "Metal Blade": 7, + "Crash Bomber": 6, + "Time Stopper": 8, +} + + +class EnergyLink(Toggle): + """ + Enables EnergyLink support. + When enabled, pickups dropped from enemies are sent to the EnergyLink pool, and healing/weapon energy/1-Ups can + be requested from the EnergyLink pool. + Some of the energy sent to the pool will be lost on transfer. + """ + display_name = "EnergyLink" + + +class StartingRobotMaster(Choice): + """ + The initial stage unlocked at the start. + """ + display_name = "Starting Robot Master" + option_heat_man = 0 + option_air_man = 1 + option_wood_man = 2 + option_bubble_man = 3 + option_quick_man = 4 + option_flash_man = 5 + option_metal_man = 6 + option_crash_man = 7 + default = "random" + + +class YokuJumps(Toggle): + """ + When enabled, the player is expected to be able to perform the yoku block sequence in Heat Man's + stage without Item 2. + """ + display_name = "Yoku Block Jumps" + + +class EnableLasers(Toggle): + """ + When enabled, the player is expected to complete (and acquire items within) the laser sections of Quick Man's + stage without the Time Stopper. + """ + display_name = "Enable Lasers" + + +class Consumables(Choice): + """ + When enabled, e-tanks/1-ups/health/weapon energy will be added to the pool of items and included as checks. + E-Tanks and 1-Ups add 20 checks to the pool. + Weapon/Health Energy add 27 checks to the pool. + """ + display_name = "Consumables" + option_none = 0 + option_1up_etank = 1 + option_weapon_health = 2 + option_all = 3 + default = 1 + alias_true = 3 + alias_false = 0 + + @classmethod + def get_option_name(cls, value: int) -> str: + if value == 1: + return "1-Ups/E-Tanks" + if value == 2: + return "Weapon/Health Energy" + return super().get_option_name(value) + + +class Quickswap(DefaultOnToggle): + """ + When enabled, the player can quickswap through all received weapons by pressing Select. + """ + display_name = "Quickswap" + + +class PaletteShuffle(TextChoice): + """ + Change the color of Mega Man and the Robot Masters. + None: The palettes are unchanged. + Shuffled: Palette colors are shuffled amongst the robot masters. + Randomized: Random (usually good) palettes are generated for each robot master. + Singularity: one palette is generated and used for all robot masters. + Supports custom palettes using HTML named colors in the + following format: Mega Buster-Lavender|Violet;randomized + The first value is the character whose palette you'd like to define, then separated by - is a set of 2 colors for + that character. separate every color with a pipe, and separate every character as well as the remaining shuffle with + a semicolon. + """ + display_name = "Palette Shuffle" + option_none = 0 + option_shuffled = 1 + option_randomized = 2 + option_singularity = 3 + + +class EnemyWeaknesses(Toggle): + """ + Randomizes the damage dealt to enemies by weapons. Friender will always take damage from the buster. + """ + display_name = "Random Enemy Weaknesses" + + +class StrictWeaknesses(Toggle): + """ + Only your starting Robot Master will take damage from the Mega Buster, the rest must be defeated with weapons. + Weapons that only do 1-3 damage to bosses no longer deal damage (aside from Alien). + """ + display_name = "Strict Boss Weaknesses" + + +class RandomWeaknesses(Choice): + """ + None: Bosses will have their regular weaknesses. + Shuffled: Weapon damage will be shuffled amongst the weapons, so Metal Blade may do Bubble Lead damage. + Time Stopper will deplete half of a random Robot Master's HP. + Randomized: Weapon damage will be fully randomized. + """ + display_name = "Random Boss Weaknesses" + option_none = 0 + option_shuffled = 1 + option_randomized = 2 + alias_false = 0 + alias_true = 2 + + +class Wily5Requirement(Range): + """Change the number of Robot Masters that are required to be defeated for + the teleporter to the Wily Machine to appear.""" + display_name = "Wily 5 Requirement" + default = 8 + range_start = 1 + range_end = 8 + + +class WeaknessPlando(OptionDict): + """ + Specify specific damage numbers for boss damage. Can be used even without strict/random weaknesses. + plando_weakness: + Robot Master: + Weapon: Damage + """ + display_name = "Plando Weaknesses" + schema = Schema({ + Optional(And(str, Use(str.title), lambda s: s in bosses)): { + And(str, Use(str.title), lambda s: s in weapons_to_id): And(int, lambda i: i in range(-1, 14)) + } + }) + default = {} + + +class ReduceFlashing(Choice): + """ + Reduce flashing seen in gameplay, such as the stage select and when defeating a Wily boss. + Virtual Console: increases length of most flashes, changes some flashes from white to a dark gray. + Minor: VC changes + decreasing the speed of Bubble/Metal Man stage animations. + Full: VC changes + further decreasing the brightness of most flashes and + disables stage animations for Metal/Bubble Man stages. + """ + display_name = "Reduce Flashing" + option_none = 0 + option_virtual_console = 1 + option_minor = 2 + option_full = 3 + default = 1 + + +class RandomMusic(Choice): + """ + Vanilla: music is unchanged + Shuffled: stage and certain menu music is shuffled. + Randomized: stage and certain menu music is randomly selected + None: no music will play + """ + display_name = "Random Music" + option_vanilla = 0 + option_shuffled = 1 + option_randomized = 2 + option_none = 3 + +@dataclass +class MM2Options(PerGameCommonOptions): + death_link: DeathLink + energy_link: EnergyLink + starting_robot_master: StartingRobotMaster + consumables: Consumables + yoku_jumps: YokuJumps + enable_lasers: EnableLasers + enemy_weakness: EnemyWeaknesses + strict_weakness: StrictWeaknesses + random_weakness: RandomWeaknesses + wily_5_requirement: Wily5Requirement + plando_weakness: WeaknessPlando + palette_shuffle: PaletteShuffle + quickswap: Quickswap + reduce_flashing: ReduceFlashing + random_music: RandomMusic diff --git a/worlds/mm2/rom.py b/worlds/mm2/rom.py new file mode 100644 index 000000000000..cac0a8706007 --- /dev/null +++ b/worlds/mm2/rom.py @@ -0,0 +1,415 @@ +import pkgutil +from typing import Optional, TYPE_CHECKING, Iterable, Dict, Sequence +import hashlib +import Utils +import os + +import settings +from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes +from . import names +from .rules import minimum_weakness_requirement +from .text import MM2TextEntry +from .color import get_colors_for_item, write_palette_shuffle +from .options import Consumables, ReduceFlashing, RandomMusic + +if TYPE_CHECKING: + from . import MM2World + +MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497" +PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4" +MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632" +MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3" + +enemy_weakness_ptrs: Dict[int, int] = { + 0: 0x3E9A8, + 1: 0x3EA24, + 2: 0x3EA9C, + 3: 0x3EB14, + 4: 0x3EB8C, + 5: 0x3EC04, + 6: 0x3EC7C, + 7: 0x3ECF4, +} + +enemy_addresses: Dict[str, int] = { + "Shrink": 0x00, + "M-445": 0x04, + "Claw": 0x08, + "Tanishi": 0x0A, + "Kerog": 0x0C, + "Petit Kerog": 0x0D, + "Anko": 0x0F, + "Batton": 0x16, + "Robitto": 0x17, + "Friender": 0x1C, + "Monking": 0x1D, + "Kukku": 0x1F, + "Telly": 0x22, + "Changkey Maker": 0x23, + "Changkey": 0x24, + "Pierrobot": 0x29, + "Fly Boy": 0x2C, + # "Crash Wall": 0x2D + # "Friender Wall": 0x2E + "Blocky": 0x31, + "Neo Metall": 0x34, + "Matasaburo": 0x36, + "Pipi": 0x38, + "Pipi Egg": 0x3A, + "Copipi": 0x3C, + "Kaminari Goro": 0x3E, + "Petit Goblin": 0x45, + "Springer": 0x46, + "Mole (Up)": 0x48, + "Mole (Down)": 0x49, + "Shotman (Left)": 0x4B, + "Shotman (Right)": 0x4C, + "Sniper Armor": 0x4E, + "Sniper Joe": 0x4F, + "Scworm": 0x50, + "Scworm Worm": 0x51, + "Picopico-kun": 0x6A, + "Boobeam Trap": 0x6D, + "Big Fish": 0x71 +} + +# addresses printed when assembling basepatch +consumables_ptr: int = 0x3F2FE +quickswap_ptr: int = 0x3F363 +wily_5_ptr: int = 0x3F3A1 +energylink_ptr: int = 0x3F46B +get_equipped_sound_ptr: int = 0x3F384 + + +class RomData: + def __init__(self, file: bytes, name: str = "") -> None: + self.file = bytearray(file) + self.name = name + + def read_byte(self, offset: int) -> int: + return self.file[offset] + + def read_bytes(self, offset: int, length: int) -> bytearray: + return self.file[offset:offset + length] + + def write_byte(self, offset: int, value: int) -> None: + self.file[offset] = value + + def write_bytes(self, offset: int, values: Sequence[int]) -> None: + self.file[offset:offset + len(values)] = values + + def write_to_file(self, file: str) -> None: + with open(file, 'wb') as outfile: + outfile.write(self.file) + + +class MM2ProcedurePatch(APProcedurePatch, APTokenMixin): + hash = [MM2LCHASH, MM2NESHASH, MM2VCHASH] + game = "Mega Man 2" + patch_file_ending = ".apmm2" + result_file_ending = ".nes" + name: bytearray + procedure = [ + ("apply_bsdiff4", ["mm2_basepatch.bsdiff4"]), + ("apply_tokens", ["token_patch.bin"]), + ] + + @classmethod + def get_source_data(cls) -> bytes: + return get_base_rom_bytes() + + def write_byte(self, offset: int, value: int) -> None: + self.write_token(APTokenTypes.WRITE, offset, value.to_bytes(1, "little")) + + def write_bytes(self, offset: int, value: Iterable[int]) -> None: + self.write_token(APTokenTypes.WRITE, offset, bytes(value)) + + +def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None: + patch.write_file("mm2_basepatch.bsdiff4", pkgutil.get_data(__name__, os.path.join("data", "mm2_basepatch.bsdiff4"))) + # text writing + patch.write_bytes(0x37E2A, MM2TextEntry("FOR ", 0xCB).resolve()) + patch.write_bytes(0x37EAA, MM2TextEntry("GET EQUIPPED ", 0x0B).resolve()) + patch.write_bytes(0x37EBA, MM2TextEntry("WITH ", 0x2B).resolve()) + + base_address = 0x3F650 + color_address = 0x37F6C + for i, location in zip(range(11), [ + names.atomic_fire_get, + names.air_shooter_get, + names.leaf_shield_get, + names.bubble_lead_get, + names.quick_boomerang_get, + names.time_stopper_get, + names.metal_blade_get, + names.crash_bomber_get, + names.item_1_get, + names.item_2_get, + names.item_3_get + ]): + item = world.multiworld.get_location(location, world.player).item + if item: + if len(item.name) <= 14: + # we want to just place it in the center + first_str = "" + second_str = item.name + third_str = "" + elif len(item.name) <= 28: + # spread across second and third + first_str = "" + second_str = item.name[:14] + third_str = item.name[14:] + else: + # all three + first_str = item.name[:14] + second_str = item.name[14:28] + third_str = item.name[28:] + if len(third_str) > 16: + third_str = third_str[:16] + player_str = world.multiworld.get_player_name(item.player) + if len(player_str) > 14: + player_str = player_str[:14] + patch.write_bytes(base_address + (64 * i), MM2TextEntry(first_str, 0x4B).resolve()) + patch.write_bytes(base_address + (64 * i) + 16, MM2TextEntry(second_str, 0x6B).resolve()) + patch.write_bytes(base_address + (64 * i) + 32, MM2TextEntry(third_str, 0x8B).resolve()) + patch.write_bytes(base_address + (64 * i) + 48, MM2TextEntry(player_str, 0xEB).resolve()) + + colors = get_colors_for_item(item.name) + if i > 7: + patch.write_bytes(color_address + 27 + ((i - 8) * 2), colors) + else: + patch.write_bytes(color_address + (i * 2), colors) + + write_palette_shuffle(world, patch) + + enemy_weaknesses: Dict[str, Dict[int, int]] = {} + + if world.options.strict_weakness or world.options.random_weakness or world.options.plando_weakness: + # we need to write boss weaknesses + output = bytearray() + for weapon in world.weapon_damage: + if weapon == 8: + continue # Time Stopper is a special case + weapon_damage = [world.weapon_damage[weapon][i] + if world.weapon_damage[weapon][i] >= 0 + else 256 + world.weapon_damage[weapon][i] + for i in range(14)] + output.extend(weapon_damage) + patch.write_bytes(0x2E952, bytes(output)) + time_stopper_damage = world.weapon_damage[8] + time_offset = 0x2C03B + damage_table = { + 4: 0xF, + 3: 0x17, + 2: 0x1E, + 1: 0x25 + } + for boss, damage in enumerate(time_stopper_damage): + if damage > 4: + damage = 4 # 4 is a guaranteed kill, no need to exceed + if damage <= 0: + patch.write_byte(time_offset + 14 + boss, 0) + else: + patch.write_byte(time_offset + 14 + boss, 1) + patch.write_byte(time_offset + boss, damage_table[damage]) + if world.options.random_weakness: + wily_5_weaknesses = [i for i in range(8) if world.weapon_damage[i][12] > minimum_weakness_requirement[i]] + world.random.shuffle(wily_5_weaknesses) + if len(wily_5_weaknesses) >= 3: + weak1 = wily_5_weaknesses.pop() + weak2 = wily_5_weaknesses.pop() + weak3 = wily_5_weaknesses.pop() + elif len(wily_5_weaknesses) == 2: + weak1 = weak2 = wily_5_weaknesses.pop() + weak3 = wily_5_weaknesses.pop() + else: + weak1 = weak2 = weak3 = 0 + patch.write_byte(0x2DA2E, weak1) + patch.write_byte(0x2DA32, weak2) + patch.write_byte(0x2DA3A, weak3) + enemy_weaknesses["Picopico-kun"] = {weapon: world.weapon_damage[weapon][9] for weapon in range(8)} + enemy_weaknesses["Boobeam Trap"] = {weapon: world.weapon_damage[weapon][11] for weapon in range(8)} + + if world.options.enemy_weakness: + for enemy in enemy_addresses: + if enemy in ("Picopico-kun", "Boobeam Trap"): + continue + enemy_weaknesses[enemy] = {weapon: world.random.randint(-4, 4) for weapon in enemy_weakness_ptrs} + if enemy == "Friender": + # Friender has to be killed, need buster damage to not break logic + enemy_weaknesses[enemy][0] = max(enemy_weaknesses[enemy][0], 1) + + for enemy, damage_table in enemy_weaknesses.items(): + for weapon in enemy_weakness_ptrs: + if damage_table[weapon] < 0: + damage_table[weapon] = 256 + damage_table[weapon] + patch.write_byte(enemy_weakness_ptrs[weapon] + enemy_addresses[enemy], damage_table[weapon]) + + if world.options.quickswap: + patch.write_byte(quickswap_ptr + 1, 0x01) + + if world.options.consumables != Consumables.option_all: + value_a = 0x7C + value_b = 0x76 + if world.options.consumables == Consumables.option_1up_etank: + value_b = 0x7A + else: + value_a = 0x7A + patch.write_byte(consumables_ptr - 3, value_a) + patch.write_byte(consumables_ptr + 1, value_b) + + patch.write_byte(wily_5_ptr + 1, world.options.wily_5_requirement.value) + + if world.options.energy_link: + patch.write_byte(energylink_ptr + 1, 1) + + if world.options.reduce_flashing: + if world.options.reduce_flashing.value == ReduceFlashing.option_virtual_console: + color = 0x2D # Dark Gray + speed = -1 + elif world.options.reduce_flashing.value == ReduceFlashing.option_minor: + color = 0x2D + speed = 0x08 + else: + color = 0x0F + speed = 0x00 + patch.write_byte(0x2D1B0, color) # Change white to a dark gray, Mecha Dragon + patch.write_byte(0x2D397, 0x0F) # Longer flash time, Mecha Dragon kill + patch.write_byte(0x2D3A0, color) # Change white to a dark gray, Picopico-kun/Boobeam Trap + patch.write_byte(0x2D65F, color) # Change white to a dark gray, Guts Tank + patch.write_byte(0x2DA94, color) # Change white to a dark gray, Wily Machine + patch.write_byte(0x2DC97, color) # Change white to a dark gray, Alien + patch.write_byte(0x2DD68, 0x10) # Longer flash time, Alien kill + patch.write_bytes(0x2DF14, [0xEA, 0xEA, 0xEA, 0xEA, 0xEA, 0xEA]) # Reduce final Alien flash to 1 big flash + patch.write_byte(0x34132, 0x08) # Longer flash time, Stage Select + + if world.options.reduce_flashing.value == ReduceFlashing.option_full: + # reduce color of stage flashing + patch.write_bytes(0x344C9, [0x2D, 0x10, 0x00, 0x2D, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x2D, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00, + 0x0F, 0x10, 0x2D, 0x00]) + # remove wily castle flash + patch.write_byte(0x3596D, 0x0F) + + if speed != -1: + patch.write_byte(0xFE01, speed) # Bubble Man Stage + patch.write_byte(0x1BE01, speed) # Metal Man Stage + + if world.options.random_music: + if world.options.random_music == RandomMusic.option_none: + pool = [0xFF] * 20 + # A couple of additional mutes we want here + patch.write_byte(0x37819, 0xFF) # Credits + patch.write_byte(0x378A4, 0xFF) # Credits #2 + patch.write_byte(0x37149, 0xFF) # Game Over Jingle + patch.write_byte(0x341BA, 0xFF) # Robot Master Jingle + patch.write_byte(0x2E0B4, 0xFF) # Robot Master Defeated + patch.write_byte(0x35B78, 0xFF) # Wily Castle + patch.write_byte(0x2DFA5, 0xFF) # Wily Defeated + + elif world.options.random_music == RandomMusic.option_shuffled: + pool = [0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 9, 9, 9, 0x10, 0xC, 0xB, 0x17, 0x13, 0xE, 0xD] + world.random.shuffle(pool) + else: + pool = world.random.choices([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0xB, 0xC, 0xD, 0xE, 0x10, 0x13, 0x17], k=20) + patch.write_bytes(0x381E0, pool[:13]) + patch.write_byte(0x36318, pool[13]) # Game Start + patch.write_byte(0x37181, pool[13]) # Game Over + patch.write_byte(0x340AE, pool[14]) # RBM Select + patch.write_byte(0x39005, pool[15]) # Robot Master Battle + patch.write_byte(get_equipped_sound_ptr + 1, pool[16]) # Get Equipped, we actually hook this already lmao + patch.write_byte(0x3775A, pool[17]) # Epilogue + patch.write_byte(0x36089, pool[18]) # Intro + patch.write_byte(0x361F1, pool[19]) # Title + + + + from Utils import __version__ + patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', + 'utf8')[:21] + patch.name.extend([0] * (21 - len(patch.name))) + patch.write_bytes(0x3FFC0, patch.name) + deathlink_byte = world.options.death_link.value | (world.options.energy_link.value << 1) + patch.write_byte(0x3FFD5, deathlink_byte) + + patch.write_bytes(0x3FFD8, world.world_version) + + version_map = { + "0": 0x90, + "1": 0x91, + "2": 0x92, + "3": 0x93, + "4": 0x94, + "5": 0x95, + "6": 0x96, + "7": 0x97, + "8": 0x98, + "9": 0x99, + ".": 0xDC + } + patch.write_token(APTokenTypes.RLE, 0x36EE0, (11, 0)) + patch.write_token(APTokenTypes.RLE, 0x36EEE, (25, 0)) + + # BY SILVRIS + patch.write_bytes(0x36EE0, [0xC2, 0xD9, 0xC0, 0xD3, 0xC9, 0xCC, 0xD6, 0xD2, 0xC9, 0xD3]) + # ARCHIPELAGO x.x.x + patch.write_bytes(0x36EF2, [0xC1, 0xD2, 0xC3, 0xC8, 0xC9, 0xD0, 0xC5, 0xCC, 0xC1, 0xC7, 0xCF, 0xC0]) + patch.write_bytes(0x36EFE, list(map(lambda c: version_map[c], __version__))) + + patch.write_file("token_patch.bin", patch.get_token_binary()) + + +header = b"\x4E\x45\x53\x1A\x10\x00\x10\x00\x00\x00\x00\x00\x00\x00\x00\x00" + + +def read_headerless_nes_rom(rom: bytes) -> bytes: + if rom[:4] == b"NES\x1A": + return rom[16:] + else: + return rom + + +def get_base_rom_bytes(file_name: str = "") -> bytes: + base_rom_bytes: Optional[bytes] = getattr(get_base_rom_bytes, "base_rom_bytes", None) + if not base_rom_bytes: + file_name = get_base_rom_path(file_name) + base_rom_bytes = read_headerless_nes_rom(bytes(open(file_name, "rb").read())) + + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() == PROTEUSHASH: + base_rom_bytes = extract_mm2(base_rom_bytes) + basemd5 = hashlib.md5() + basemd5.update(base_rom_bytes) + if basemd5.hexdigest() not in {MM2LCHASH, MM2NESHASH, MM2VCHASH}: + print(basemd5.hexdigest()) + raise Exception("Supplied Base Rom does not match known MD5 for US, LC, or US VC release. " + "Get the correct game and version, then dump it") + headered_rom = bytearray(base_rom_bytes) + headered_rom[0:0] = header + setattr(get_base_rom_bytes, "base_rom_bytes", bytes(headered_rom)) + return bytes(headered_rom) + return base_rom_bytes + + +def get_base_rom_path(file_name: str = "") -> str: + options: settings.Settings = settings.get_settings() + if not file_name: + file_name = options["mm2_options"]["rom_file"] + if not os.path.exists(file_name): + file_name = Utils.user_path(file_name) + return file_name + + +PRG_OFFSET = 0x8ED70 +PRG_SIZE = 0x40000 + + +def extract_mm2(proteus: bytes) -> bytes: + mm2 = bytearray(proteus[PRG_OFFSET:PRG_OFFSET + PRG_SIZE]) + return bytes(mm2) diff --git a/worlds/mm2/rules.py b/worlds/mm2/rules.py new file mode 100644 index 000000000000..43d4b5a6aabd --- /dev/null +++ b/worlds/mm2/rules.py @@ -0,0 +1,319 @@ +from math import ceil +from typing import TYPE_CHECKING, Dict, List +from . import names +from .locations import heat_man_locations, air_man_locations, wood_man_locations, bubble_man_locations, \ + quick_man_locations, flash_man_locations, metal_man_locations, crash_man_locations, wily_1_locations, \ + wily_2_locations, wily_3_locations, wily_4_locations, wily_5_locations, wily_6_locations +from .options import bosses, weapons_to_id, Consumables, RandomWeaknesses +from worlds.generic.Rules import add_rule + +if TYPE_CHECKING: + from . import MM2World + from BaseClasses import CollectionState + +weapon_damage: Dict[int, List[int]] = { + 0: [2, 2, 1, 1, 2, 2, 1, 1, 1, 7, 1, 0, 1, -1], # Mega Buster + 1: [-1, 6, 0xE, 0, 0xA, 6, 4, 6, 8, 13, 8, 0, 0xE, -1], # Atomic Fire + 2: [2, 0, 4, 0, 2, 0, 0, 0xA, 0, 0, 0, 0, 1, -1], # Air Shooter + 3: [0, 8, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1], # Leaf Shield + 4: [6, 0, 0, -1, 0, 2, 0, 1, 0, 14, 1, 0, 0, 1], # Bubble Lead + 5: [2, 2, 0, 2, 0, 0, 4, 1, 1, 7, 2, 0, 1, -1], # Quick Boomerang + 6: [-1, 0, 2, 2, 4, 3, 0, 0, 1, 0, 1, 0x14, 1, -1], # Crash Bomber + 7: [1, 0, 2, 4, 0, 4, 0xE, 0, 0, 7, 0, 0, 1, -1], # Metal Blade + 8: [0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0], # Time Stopper +} + +weapons_to_name: Dict[int, str] = { + 1: names.atomic_fire, + 2: names.air_shooter, + 3: names.leaf_shield, + 4: names.bubble_lead, + 5: names.quick_boomerang, + 6: names.crash_bomber, + 7: names.metal_blade, + 8: names.time_stopper +} + +minimum_weakness_requirement: Dict[int, int] = { + 0: 1, # Mega Buster is free + 1: 14, # 2 shots of Atomic Fire + 2: 1, # 14 shots of Air Shooter, although you likely hit more than one shot + 3: 4, # 9 uses of Leaf Shield, 3 ends up 1 damage off + 4: 1, # 56 uses of Bubble Lead + 5: 1, # 224 uses of Quick Boomerang + 6: 4, # 7 uses of Crash Bomber + 7: 1, # 112 uses of Metal Blade + 8: 4, # 1 use of Time Stopper, but setting to 4 means we shave the entire HP bar +} + +robot_masters: Dict[int, str] = { + 0: "Heat Man Defeated", + 1: "Air Man Defeated", + 2: "Wood Man Defeated", + 3: "Bubble Man Defeated", + 4: "Quick Man Defeated", + 5: "Flash Man Defeated", + 6: "Metal Man Defeated", + 7: "Crash Man Defeated" +} + +weapon_costs = { + 0: 0, + 1: 10, + 2: 2, + 3: 3, + 4: 0.5, + 5: 0.125, + 6: 4, + 7: 0.25, + 8: 7, +} + + +def can_defeat_enough_rbms(state: "CollectionState", player: int, + required: int, boss_requirements: Dict[int, List[int]]): + can_defeat = 0 + for boss, reqs in boss_requirements.items(): + if boss in robot_masters: + if state.has_all(map(lambda x: weapons_to_name[x], reqs), player): + can_defeat += 1 + if can_defeat >= required: + return True + return False + + +def set_rules(world: "MM2World") -> None: + # most rules are set on region, so we only worry about rules required within stage access + # or rules variable on settings + if (hasattr(world.multiworld, "re_gen_passthrough") + and "Mega Man 2" in getattr(world.multiworld, "re_gen_passthrough")): + slot_data = getattr(world.multiworld, "re_gen_passthrough")["Mega Man 2"] + world.weapon_damage = slot_data["weapon_damage"] + world.wily_5_weapons = slot_data["wily_5_weapons"] + else: + if world.options.random_weakness == RandomWeaknesses.option_shuffled: + weapon_tables = [table for weapon, table in weapon_damage.items() if weapon not in (0, 8)] + world.random.shuffle(weapon_tables) + for i in range(1, 8): + world.weapon_damage[i] = weapon_tables.pop() + # alien must take minimum required damage from his weakness + alien_weakness = next(weapon for weapon in range(8) if world.weapon_damage[weapon][13] != -1) + world.weapon_damage[alien_weakness][13] = minimum_weakness_requirement[alien_weakness] + world.weapon_damage[8] = [0 for _ in range(14)] + world.weapon_damage[8][world.random.choice(range(8))] = 2 + elif world.options.random_weakness == RandomWeaknesses.option_randomized: + world.weapon_damage = {i: [] for i in range(9)} + for boss in range(13): + for weapon in world.weapon_damage: + world.weapon_damage[weapon].append(min(14, max(-1, int(world.random.normalvariate(3, 3))))) + if not any([world.weapon_damage[weapon][boss] >= max(4, minimum_weakness_requirement[weapon]) + for weapon in range(1, 7)]): + # failsafe, there should be at least one defined non-Buster weakness + weapon = world.random.randint(1, 7) + world.weapon_damage[weapon][boss] = world.random.randint( + max(4, minimum_weakness_requirement[weapon]), 14) # Force weakness + # special case, if boobeam trap has a weakness to Crash, it needs to be max damage + if world.weapon_damage[6][11] > 4: + world.weapon_damage[6][11] = 14 + # handle the alien + boss = 13 + for weapon in world.weapon_damage: + world.weapon_damage[weapon].append(-1) + weapon = world.random.choice(list(world.weapon_damage.keys())) + world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon] + + if world.options.strict_weakness: + for weapon in weapon_damage: + for i in range(13): + if weapon == 0: + world.weapon_damage[weapon][i] = 0 + elif i in (8, 12) and not world.options.random_weakness: + continue + # Mecha Dragon only has damage range of 0-1, so allow the 1 + # Wily Machine needs all three weaknesses present, so allow + elif 4 > world.weapon_damage[weapon][i] > 0: + world.weapon_damage[weapon][i] = 0 + # handle special cases + for boss in range(14): + for weapon in (1, 3, 6, 8): + if (0 < world.weapon_damage[weapon][boss] < minimum_weakness_requirement[weapon] and + not any(world.weapon_damage[i][boss] > 0 for i in range(1, 8) if i != weapon)): + # Weapon does not have enough possible ammo to kill the boss, raise the damage + if boss == 9: + if weapon != 3: + # Atomic Fire and Crash Bomber cannot be Picopico-kun's only weakness + world.weapon_damage[weapon][boss] = 0 + weakness = world.random.choice((2, 3, 4, 5, 7, 8)) + world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness] + elif boss == 11: + if weapon == 1: + # Atomic Fire cannot be Boobeam Trap's only weakness + world.weapon_damage[weapon][boss] = 0 + weakness = world.random.choice((2, 3, 4, 5, 6, 7, 8)) + world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness] + else: + world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon] + starting = world.options.starting_robot_master.value + world.weapon_damage[0][starting] = 1 + + for p_boss in world.options.plando_weakness: + for p_weapon in world.options.plando_weakness[p_boss]: + if world.options.plando_weakness[p_boss][p_weapon] < minimum_weakness_requirement[p_weapon] \ + and not any(w != p_weapon + and world.weapon_damage[w][bosses[p_boss]] > minimum_weakness_requirement[w] + for w in world.weapon_damage): + # we need to replace this weakness + weakness = world.random.choice([key for key in world.weapon_damage if key != p_weapon]) + world.weapon_damage[weakness][bosses[p_boss]] = minimum_weakness_requirement[weakness] + world.weapon_damage[weapons_to_id[p_weapon]][bosses[p_boss]] \ + = world.options.plando_weakness[p_boss][p_weapon] + + if world.weapon_damage[0][world.options.starting_robot_master.value] < 1: + world.weapon_damage[0][world.options.starting_robot_master.value] = weapon_damage[0][world.options.starting_robot_master.value] + + # final special case + # There's a vanilla crash if Time Stopper kills Wily phase 1 + # There's multiple fixes, but ensuring Wily cannot take Time Stopper damage is best + if world.weapon_damage[8][12] > 0: + world.weapon_damage[8][12] = 0 + + # weakness validation, it is better to confirm a completable seed than respect plando + boss_health = {boss: 0x1C if boss != 12 else 0x1C * 2 for boss in [*range(8), 12]} + + weapon_energy = {key: float(0x1C) for key in weapon_costs} + weapon_boss = {boss: {weapon: world.weapon_damage[weapon][boss] for weapon in world.weapon_damage} + for boss in [*range(8), 12]} + flexibility = { + boss: ( + sum(damage_value > 0 for damage_value in + weapon_damages.values()) # Amount of weapons that hit this boss + * sum(weapon_damages.values()) # Overall damage that those weapons do + ) + for boss, weapon_damages in weapon_boss.items() if boss != 12 + } + flexibility = sorted(flexibility, key=flexibility.get) # Fast way to sort dict by value + used_weapons = {i: set() for i in [*range(8), 12]} + for boss in [*flexibility, 12]: + boss_damage = weapon_boss[boss] + weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in + boss_damage.items() if weapon_energy[weapon] > 0} + if any(boss_damage[i] > 0 for i in range(8)) and 8 in weapon_weight: + # We get exactly one use of Time Stopper during the rush + # So we want to make sure that use is absolutely needed + weapon_weight[8] = min(weapon_weight[8], 0.001) + while boss_health[boss] > 0: + if boss_damage[0] > 0: + boss_health[boss] = 0 # if we can buster, we should buster + continue + highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys())) + uses = weapon_energy[wp] // weapon_costs[wp] + used_weapons[boss].add(wp) + if int(uses * boss_damage[wp]) > boss_health[boss]: + used = ceil(boss_health[boss] / boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * used + boss_health[boss] = 0 + elif highest <= 0: + # we are out of weapons that can actually damage the boss + # so find the weapon that has the most uses, and apply that as an additional weakness + # it should be impossible to be out of energy, simply because even if every boss took 1 from + # Quick Boomerang and no other, it would only be 28 off from defeating all 9, which Metal Blade should + # be able to cover + wp, max_uses = max((weapon, weapon_energy[weapon] // weapon_costs[weapon]) for weapon in weapon_weight + if weapon != 0) + world.weapon_damage[wp][boss] = minimum_weakness_requirement[wp] + used = min(int(weapon_energy[wp] // weapon_costs[wp]), + ceil(boss_health[boss] // minimum_weakness_requirement[wp])) + weapon_energy[wp] -= weapon_costs[wp] * used + boss_health[boss] -= int(used * minimum_weakness_requirement[wp]) + weapon_weight.pop(wp) + else: + # drain the weapon and continue + boss_health[boss] -= int(uses * boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * uses + weapon_weight.pop(wp) + + world.wily_5_weapons = {boss: sorted(used_weapons[boss]) for boss in used_weapons} + + for i, boss_locations in enumerate([ + heat_man_locations, + air_man_locations, + wood_man_locations, + bubble_man_locations, + quick_man_locations, + flash_man_locations, + metal_man_locations, + crash_man_locations, + wily_1_locations, + wily_2_locations, + wily_3_locations, + wily_4_locations, + wily_5_locations, + wily_6_locations + ]): + if world.weapon_damage[0][i] > 0: + continue # this can always be in logic + weapons = [] + for weapon in range(1, 9): + if world.weapon_damage[weapon][i] > 0: + if world.weapon_damage[weapon][i] < minimum_weakness_requirement[weapon]: + continue # Atomic Fire can only be considered logical for bosses it can kill in 2 hits + weapons.append(weapons_to_name[weapon]) + if not weapons: + raise Exception(f"Attempted to have boss {i} with no weakness! Seed: {world.multiworld.seed}") + for location in boss_locations: + if i == 12: + add_rule(world.get_location(location), + lambda state, weps=tuple(weapons): state.has_all(weps, world.player)) + # TODO: when has_list gets added, check for a subset of possible weaknesses + else: + add_rule(world.get_location(location), + lambda state, weps=tuple(weapons): state.has_any(weps, world.player)) + + # Always require Crash Bomber for Boobeam Trap + add_rule(world.get_location(names.wily_4), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_stage_4), + lambda state: state.has(names.crash_bomber, world.player)) + + # Need to defeat x amount of robot masters for Wily 5 + add_rule(world.get_location(names.wily_5), + lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_5_requirement.value, + world.wily_5_weapons)) + add_rule(world.get_location(names.wily_stage_5), + lambda state: can_defeat_enough_rbms(state, world.player, world.options.wily_5_requirement.value, + world.wily_5_weapons)) + + if not world.options.yoku_jumps: + add_rule(world.get_entrance("To Heat Man Stage"), + lambda state: state.has(names.item_2, world.player)) + + if not world.options.enable_lasers: + add_rule(world.get_entrance("To Quick Man Stage"), + lambda state: state.has(names.time_stopper, world.player)) + + if world.options.consumables in (Consumables.option_1up_etank, + Consumables.option_all): + add_rule(world.get_location(names.flash_man_c2), + lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player)) + add_rule(world.get_location(names.quick_man_c1), + lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player)) + add_rule(world.get_location(names.metal_man_c2), + lambda state: state.has_any([names.item_1, names.item_2], world.player)) + add_rule(world.get_location(names.metal_man_c3), + lambda state: state.has_any([names.item_1, names.item_2], world.player)) + add_rule(world.get_location(names.crash_man_c3), + lambda state: state.has_any([names.item_1, names.item_2, names.item_3], world.player)) + add_rule(world.get_location(names.wily_2_c5), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_2_c6), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_3_c2), + lambda state: state.has(names.crash_bomber, world.player)) + if world.options.consumables in (Consumables.option_weapon_health, + Consumables.option_all): + add_rule(world.get_location(names.flash_man_c3), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.flash_man_c4), + lambda state: state.has(names.crash_bomber, world.player)) + add_rule(world.get_location(names.wily_3_c1), + lambda state: state.has(names.crash_bomber, world.player)) diff --git a/worlds/mm2/src/mm2_basepatch.asm b/worlds/mm2/src/mm2_basepatch.asm new file mode 100644 index 000000000000..00c8500f03df --- /dev/null +++ b/worlds/mm2/src/mm2_basepatch.asm @@ -0,0 +1,861 @@ +norom +!headersize = 16 + +!controller_mirror = $23 +!controller_flip = $27 ; only on first frame of input, used by crash man, etc +!current_stage = $2A +!received_stages = $8A +!completed_stages = $8B +!received_item_checks = $8C +!last_wily = $8D +!deathlink = $8F +!energylink_packet = $90 +!rbm_strobe = $91 +!received_weapons = $9A +!received_items = $9B +!current_weapon = $A9 + +!stage_completion = $0F70 +!consumable_checks = $0F80 + +!CONTROLLER_SELECT = #$04 +!CONTROLLER_SELECT_START = #$0C +!CONTROLLER_ALL_BUTTON = #$0F + +!PpuControl_2000 = $2000 +!PpuMask_2001 = $2001 +!PpuAddr_2006 = $2006 +!PpuData_2007 = $2007 + +!LOAD_BANK = $C000 + +macro org(address,bank) + if == $0F + org
-$C000+($4000*)+!headersize ; org sets the position in the output file to write to (in norom, at least) + base
; base sets the position that all labels are relative to - this is necessary so labels will still start from $8000, instead of $0000 or somewhere + else + org
-$8000+($4000*)+!headersize + base
+ endif +endmacro + +%org($8400, $08) +incbin "mm2font.dat" + +%org($A900, $09) +incbin "mm2titlefont.dat" + +%org($807E, $0B) +FlashFixes: + CMP #$FF + BEQ FlashFixTarget1 + CMP #$FF + BNE FlashFixTarget2 + +%org($8086, $0B) +FlashFixTarget1: + +%org($808D, $0B) +FlashFixTarget2: + +%org($8015, $0D) +ClearRefreshHook: + ; if we're already doing a fresh load of the stage select + ; we don't need to immediately refresh it + JSR ClearRefresh + NOP + +%org($802B, $0D) +PatchFaceTiles: + LDA !received_stages + +%org($8072, $0D) +PatchFaceSprites: + LDA !received_stages + +%org($80CC, $0D) +CheckItemsForWily: + LDA !received_items + CMP #$07 + +%org($80D2, $0D) +LoadWily: + JSR GoToMostRecentWily + NOP + +%org($80DC, $0D) +CheckAccessCodes: + LDA !received_stages + +%org($8312, $0D) +HookStageSelect: + JSR RefreshRBMTiles + NOP + +%org($A315, $0D) +RemoveWeaponClear: + NOP + NOP + NOP + NOP + +;Adjust Password select flasher +%org($A32A, $0D) + LDX #$68 + +;Block password input +%org($A346, $0D) + EOR #$00 + +;Remove password text +%org($AF3A, $0D) +StartHeight: + db $AC ; set Start to center + +%org($AF49, $0D) +PasswordText: + db $40, $40, $40, $40, $40, $40, $40, $40 + +%org($AF6C, $0D) +ContinueHeight: + db $AB ; split height between 2 remaining options + +%org($AF77, $0D) +StageSelectHeight: + db $EB ; split between 2 remaining options + +%org($AF88, $0D) +GameOverPasswordText: + db $40, $40, $40, $40, $40, $40, $40, $40 + +%org($AFA5, $0D) +GetEquippedPasswordText: + db $40, $40, $40, $40, $40, $40, $40, $40 + +%org($AFAE, $0D) +GetEquippedStageSelect: + db $26, $EA + +%org($B195, $0D) +GameOverPasswordUp: + LDA #$01 ; originally 02, removing last option + +%org($B19F, $0D) +GameOverPassword: + CMP #$02 ; originally 03, remove the last option + +%org($B1ED, $0D) +FixupGameOverArrows: + db $68, $78 + +%org($BB74, $0D) +GetEquippedStage: + JSR StageGetEquipped + NOP #13 + +%org($BBD9, $0D) +GetEquippedDefault: + LDA #$01 + +%org($BC01, $0D) +GetEquippedPasswordRemove: + ORA #$01 ; originally EOR #$01, we always want 1 here + +%org($BCF1, $0D) +GetEquippedItem: + ADC #$07 + JSR ItemGetEquipped + JSR LoadItemsColor + NOP ; !!!! This is a load-bearing NOP. It gets branched to later in the function + LDX $FF + + +%org($BB08, $0D) +WilyProgress: + JSR StoreWilyProgress + NOP + +%org($BF6F, $0D) +GetEquippedStageSelectHeight: + db $B8 + +%org($805B, $0E) +InitalizeStartingRBM: + LDA #$FF ; this does two things + STA !received_stages ; we're overwriting clearing e-tanks and setting RBM available to none + +%org($8066, $0E) +BlockStartupAutoWily: + ; presumably this would be called from password? + LDA #$00 + +%org($80A7, $0E) +StageLoad: + JMP CleanWily5 + NOP + +%org($8178, $0E) +Main1: + JSR MainLoopHook + NOP + +%org($81DE, $0E) +Wily5Teleporter: + LDA $99 + CMP #$01 + BCC SkipSpawn + +%org($81F9, $0E) +SkipSpawn: +; just present to fix the branch, if we try to branch raw it'll get confused + +%org($822D, $0E) +Main2: + ; believe used in the wily 5 refights? + JSR MainLoopHook + NOP + +%org($842F, $0E) +Wily5Hook: + JMP Wily5Requirement + NOP + +%org($C10D, $0F) +Deathlink: + JSR KillMegaMan + +%org($C1BC, $0F) +RemoveETankLoss: + NOP + NOP + +%org($C23C, $0F) +WriteStageComplete: + ORA !completed_stages + STA !completed_stages + +%org($C243, $0F) +WriteReceiveItem: + ORA !received_item_checks + STA !received_item_checks + +%org($C254, $0F) +BlockAutoWily: + ; and this one is on return from stage? + LDA #$00 + +%org($C261, $0F) +WilyStageCompletion: + JSR StoreWilyStageCompletion + NOP + +%org($E5AC, $0F) +NullDeathlink: + STA $8F ; we null his HP later in the process + NOP + +%org($E5D1, $0F) +EnergylinkHook: + JSR Energylink + NOP #2 ; comment this out to enable item giving their usual reward alongside EL + +%org($E5E8, $0F) +ConsumableHook: + JSR CheckConsumable + +%org($F2E3, $0F) + +CheckConsumable: + STA $0140, Y + TXA + PHA + LDA $AD ; the consumable value + CMP #$7C + BPL .Store + print "Consumables (replace 7a): ", hex(realbase()) + CMP #$76 + BMI .Store + LDA #$00 + .Store: + STA $AD + LDA $2A + ASL + ASL + TAX + TYA + .LoopHead: + CMP #$08 + BMI .GetFlag + INX + SBC #$08 + BNE .LoopHead + .GetFlag: + TAY + LDA #$01 + .Loop2Head: + CPY #$00 + BEQ .Apply + ASL + DEY + BNE .Loop2Head + .Apply: + ORA !consumable_checks, X + STA !consumable_checks, X + PLA + TAX + RTS + +GoToMostRecentWily: + LDA !controller_mirror + CMP !CONTROLLER_SELECT_START + BEQ .Default + LDA !last_wily + BNE .Store + .Default: + LDA #$08 ; wily stage 1 + .Store: + STA !current_stage + RTS + +StoreWilyStageCompletion: + LDA #$01 + STA !stage_completion, X + INC !current_stage + LDA !current_stage + STA !last_wily + RTS + +ReturnToGameOver: + LDA #$10 + STA !PpuControl_2000 + LDA #$06 + STA !PpuMask_2001 + JMP $C1BE ; specific code that loads game over + +MainLoopHook: + LDA !controller_mirror + CMP !CONTROLLER_ALL_BUTTON + BNE .Next + JMP ReturnToGameOver + .Next: + LDA !deathlink + CMP #$01 + BNE .Next2 + JMP $E5A8 ; this kills the Mega Man + .Next2: + print "Quickswap:", hex(realbase()) + LDA #$00 ; slot data, write in enable for quickswap + CMP #$01 + BNE .Finally + LDA !controller_flip + AND !CONTROLLER_SELECT + BEQ .Finally + JMP Quickswap + .Finally: + LDA !controller_flip + AND #$08 ; this is checking for menu + RTS + +StoreWilyProgress: + STA !current_stage + TXA + PHA + LDX !current_stage + LDA #$01 + STA !stage_completion, X + PLA + TAX + print "Get Equipped Music: ", hex(realbase()) + LDA #$17 + RTS + +KillMegaMan: + JSR $C051 ; this kills the mega man + LDA #$00 + STA $06C0 ; set HP to zero so client can actually detect he died + RTS + +Wily5Requirement: + LDA #$01 + LDX #$08 + LDY #$00 + .LoopHead: + BIT $BC + BEQ .Skip + INY + .Skip: + DEX + ASL + CPX #$00 + BNE .LoopHead + print "Wily 5 Requirement:", hex(realbase()) + CPY #$08 + BCS .SpawnTeleporter + JMP $8450 + .SpawnTeleporter: + LDA #$FF + STA $BC + LDA #$01 + STA $99 + JMP $8433 + +CleanWily5: + LDA #$00 + STA $BC + STA $99 + JMP $80AB + +LoadString: + STY $00 + ASL + ASL + ASL + ASL + TAY + LDA $DB + ADC #$00 + STA $C8 + LDA #$40 + STA $C9 + LDA #$F6 + CLC + ADC $C8 + STA $CA + LDA ($C9), Y + STA $03B6 + TYA + CLC + ADC #$01 + TAY + LDA $CA + ADC #$00 + STA $CA + LDA ($C9), Y + STA $03B7 + TYA + CLC + ADC #$01 + TAY + LDA $CA + ADC #$00 + STA $CA + STY $FE + LDA #$0E + STA $FD + .LoopHead: + JSR $BD34 + LDY $FE + CPY #$40 + BNE .NotEqual + LDA $0420 + BNE .Skip + .NotEqual: + LDA ($C9), Y + .Skip: + STA $03B8 + INC $47 + INC $03B7 + LDA $FE + CLC + ADC #$01 + STA $FE + LDA $CA + ADC #$00 + STA $CA + DEC $FD + BNE .LoopHead + LDY $00 + JSR $C0AB + RTS + +StageGetEquipped: + LDA !current_stage + LDX #$00 + BCS LoadGetEquipped +ItemGetEquipped: + LDX #$02 +LoadGetEquipped: + STX $DB + ASL + ASL + PHA + SEC + JSR LoadString + PLA + ADC #$00 + PHA + SEC + JSR LoadString + PLA + ADC #$00 + PHA + SEC + JSR LoadString + LDA #$00 + SEC + JSR $BD3E + PLA + ADC #$00 + SEC + JSR LoadString + RTS + +LoadItemsColor: + LDA #$7D + STA $FD + LDA $0420 + AND #$0F + ASL + SEC + ADC #$1A + STA $FF + RTS + +Energylink: + LSR $0420, X + print "Energylink: ", hex(realbase()) + LDA #$00 + BEQ .ApplyDrop + LDA $04E0, X + BEQ .ApplyDrop ; This is a stage pickup, and not an enemy drop + STY !energylink_packet + SEC + BCS .Return + .ApplyDrop: + STY $AD + .Return: + RTS + + +Quickswap: + LDX #$0F + .LoopHead: + LDA $0420, X + BMI .Return1 ; return if we have any weapon entities spawned + DEX + CPX #$01 + BNE .LoopHead + LDX !current_weapon + BNE .DoQuickswap + LDX #$00 + .DoQuickswap: + TYA + PHA + LDX !current_weapon + INX + CPX #$09 + BPL .Items + LDA #$01 + .Loop2Head: + DEX + BEQ .FoundTarget + ASL + CPX #$00 + BNE .Loop2Head + .FoundTarget: + LDX !current_weapon + INX + .Loop3Head: + PHA + AND !received_weapons + BNE .CanSwap + PLA + INX + CPX #$09 + BPL .Items + ASL + BNE .Loop3Head + .CanSwap: + PLA + SEC + BCS .ApplySwap + .Items: + TXA + PHA + SEC + SBC #$08 + TAX + LDA #$01 + .Loop4Head: + DEX + BEQ .CheckItem + ASL + CPX #$00 + BNE .Loop4Head + .CheckItem: + TAY + PLA + TAX + TYA + .Loop5Head: + PHA + AND !received_items + BNE .CanSwap + PLA + INX + ASL + BNE .Loop5Head + LDX #$00 + SEC + BCS .ApplySwap + .Return1: + RTS + .ApplySwap: ; $F408 on old rom + LDA #$0D + JSR !LOAD_BANK + ; this is a bunch of boiler plate to make the swap work + LDA $B5 + PHA + LDA $B6 + PHA + LDA $B7 + PHA + LDA $B8 + PHA + LDA $B9 + PHA + LDA $20 + PHA + LDA $1F + PHA + ;but wait, there's more + STX !current_weapon + JSR $CC6C + LDA $1A + PHA + LDX #$00 + .Loop6Head: + STX $FD + CLC + LDA $52 + ADC $957F, X + STA $08 + LDA $53 + ADC #$00 + STA $09 + LDA $08 + LSR $09 + ROR + LSR $09 + ROR + STA $08 + AND #$3F + STA $1A + CLC + LDA $09 + ADC #$85 + STA $09 + LDA #$00 + STA $1B + LDA $FD + CMP #$08 + BCS .Past8 + LDX $A9 + LDA $9664, X + TAY + CPX #$09 + BCC .LessThanNine + LDX #$00 + BEQ .Apply + .LessThanNine: + LDX #$05 + BNE .Apply + .Past8: + LDY #$90 + LDX #$00 + .Apply: + JSR $C760 + JSR $C0AB ; iirc this is loading graphics? + LDX $FD + INX + CPX #$0F + BNE .Loop6Head + STX $FD + LDY #$90 + LDX #$00 + JSR $C760 + JSR $D2ED + ; two sections redacted here, might need to look at what they actually do? + PLA + STA $1A + PLA + STA $1F + PLA + STA $20 + PLA + STA $B9 + PLA + STA $B8 + PLA + STA $B7 + PLA + STA $B6 + PLA + STA $B5 + LDA #$00 + STA $AC + STA $2C + STA $0680 + STA $06A0 + LDA #$1A + STA $0400 + LDA #$03 + STA $AA + LDA #$30 + JSR $C051 + .Finally: + LDA #$0E + JSR !LOAD_BANK + PLA + TAY + .Return: + RTS + +RefreshRBMTiles: + ; primarily just a copy of the startup RBM setup, we just do it again + ; can't jump to it as it leads into the main loop + LDA !rbm_strobe + BNE .Update + JMP .NoUpdate + .Update: + LDA #$00 + STA !rbm_strobe + LDA #$10 + STA $F7 + STA !PpuControl_2000 + LDA #$06 + STA $F8 + STA !PpuMask_2001 + JSR $847E + JSR $843C + LDX #$00 + LDA $8A + STA $01 + .TileLoop: + STX $00 + LSR $01 + BCC .SkipTile + LDA $8531,X + STA $09 + LDA $8539,X + STA $08 + LDX #$04 + LDA #$00 + .ClearBody: + LDA $09 + STA !PpuAddr_2006 + LDA $08 + STA !PpuAddr_2006 + LDY #$04 + LDA #$00 + .ClearLine: + STA !PpuData_2007 + DEY + BNE .ClearLine + CLC + LDA $08 + ADC #$20 + STA $08 + DEX + BNE .ClearBody + .SkipTile: + LDX $00 + INX + CPX #$08 + BNE .TileLoop + LDX #$1F + JSR $829E + JSR $8473 + LDX #$00 + LDA $8A + STA $02 + LDY #$00 + .SpriteLoop: + STX $01 + LSR $02 + BCS .SkipRBM + LDA $8605,X + STA $00 + LDA $85FD,X + TAX + .WriteSprite: + LDA $8541,X + STA $0200,Y + INY + INX + DEC $00 + BNE .WriteSprite + .SkipRBM: + LDX $01 + INX + CPX #$08 + BNE .SpriteLoop + JSR $A51D + LDA #$0C + JSR $C051 + LDA #$00 + STA $2A + STA $FD + JSR $C0AB + .NoUpdate: + LDA $1C + AND #$08 + RTS + +ClearRefresh: + LDA #$00 + STA !rbm_strobe + LDA #$10 + STA $F7 + RTS + +assert realbase() <= $03F650 ; This is the start of our text data, and we absolutely cannot go past this point (text takes too much room). + +%org($F640, $0F) +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" +db $25, $4B, "PLACEHOLDER_L1" +db $25, $6B, "PLACEHOLDER_L2" +db $25, $8B, "PLACEHOLDER_L3" +db $25, $EB, "PLACEHOLDER_PL" + +%org($FFB0, $0F) +db "MM2_BASEPATCH_ARCHI " \ No newline at end of file diff --git a/worlds/mm2/src/mm2font.dat b/worlds/mm2/src/mm2font.dat new file mode 100644 index 0000000000000000000000000000000000000000..4bf97ee42c691a2f45e5afbe7ed5d16ec06047b8 GIT binary patch literal 416 zcmY+Au?hk)42CltM~53Sc#yF}L2>GMI2=_Jaj)aSJ&07Cd=KAD@I5ruYjXZ%==bwC zO={4x(7+@#70X~@aLI8AF8Ubzw(X1Ye7p=U5BI`AZiVapMmTntE(!qRHk*V4|4{gBNb1j=IrEQ)hX*eI@4$gS$nrJE4*Wqj W{~sHVgoT9#Ts_c!5JKT2=?4HnNh6;C literal 0 HcmV?d00001 diff --git a/worlds/mm2/test/__init__.py b/worlds/mm2/test/__init__.py new file mode 100644 index 000000000000..e712b0fe2ba6 --- /dev/null +++ b/worlds/mm2/test/__init__.py @@ -0,0 +1,5 @@ +from test.bases import WorldTestBase + + +class MM2TestBase(WorldTestBase): + game = "Mega Man 2" diff --git a/worlds/mm2/test/test_access.py b/worlds/mm2/test/test_access.py new file mode 100644 index 000000000000..97ef5075a3cb --- /dev/null +++ b/worlds/mm2/test/test_access.py @@ -0,0 +1,47 @@ +from . import MM2TestBase +from ..locations import (quick_man_locations, heat_man_locations, wily_1_locations, wily_2_locations, + wily_3_locations, wily_4_locations, wily_5_locations, wily_6_locations, + energy_pickups, etank_1ups) +from ..names import * + + +class TestAccess(MM2TestBase): + options = { + "consumables": "all" + } + + def test_time_stopper(self) -> None: + """Optional based on Enable Lasers setting, confirm these are the locations affected""" + locations = [*quick_man_locations, *energy_pickups["Quick Man Stage"], *etank_1ups["Quick Man Stage"]] + items = [["Time Stopper"]] + self.assertAccessDependency(locations, items) + + def test_item_2(self) -> None: + """Optional based on Yoku Block setting, confirm these are the locations affected""" + locations = [*heat_man_locations, *etank_1ups["Heat Man Stage"]] + items = [["Item 2 - Rocket"]] + self.assertAccessDependency(locations, items, True) + + def test_any_item(self) -> None: + locations = [flash_man_c2, quick_man_c1, crash_man_c3] + items = [["Item 1 - Propeller"], ["Item 2 - Rocket"], ["Item 3 - Bouncy"]] + self.assertAccessDependency(locations, items, True) + locations = [metal_man_c2, metal_man_c3] + items = [["Item 1 - Propeller"], ["Item 2 - Rocket"]] + self.assertAccessDependency(locations, items, True) + + def test_all_items(self) -> None: + locations = [flash_man_c2, quick_man_c1, crash_man_c3, metal_man_c2, metal_man_c3, *heat_man_locations, + *etank_1ups["Heat Man Stage"], *wily_1_locations, *wily_2_locations, *wily_3_locations, + *wily_4_locations, *wily_5_locations, *wily_6_locations, *etank_1ups["Wily Stage 1"], + *etank_1ups["Wily Stage 2"], *etank_1ups["Wily Stage 3"], *etank_1ups["Wily Stage 4"], + *energy_pickups["Wily Stage 1"], *energy_pickups["Wily Stage 2"], *energy_pickups["Wily Stage 3"], + *energy_pickups["Wily Stage 4"]] + items = [["Item 1 - Propeller", "Item 2 - Rocket", "Item 3 - Bouncy"]] + self.assertAccessDependency(locations, items) + + def test_crash_bomber(self) -> None: + locations = [flash_man_c3, flash_man_c4, wily_2_c5, wily_2_c6, wily_3_c1, wily_3_c2, + wily_4, wily_stage_4] + items = [["Crash Bomber"]] + self.assertAccessDependency(locations, items) diff --git a/worlds/mm2/test/test_weakness.py b/worlds/mm2/test/test_weakness.py new file mode 100644 index 000000000000..d3dc7b686704 --- /dev/null +++ b/worlds/mm2/test/test_weakness.py @@ -0,0 +1,93 @@ +from math import ceil + +from . import MM2TestBase +from ..options import bosses + + +# Need to figure out how this test should work +def validate_wily_5(base: MM2TestBase) -> None: + world = base.multiworld.worlds[base.player] + weapon_damage = world.weapon_damage + boss_health = {boss: 0x1C for boss in [*list(range(8)), 12]} + weapon_costs = { + 0: 0, + 1: 10, + 2: 2, + 3: 3, + 4: 0.5, + 5: 0.125, + 6: 4, + 7: 0.25, + 8: 7, + } + weapon_energy = {key: float(0x1C * 2) if key == 12 else float(0x1C) for key in weapon_costs} + weapon_boss = {boss: {weapon: weapon_damage[weapon][boss] for weapon in weapon_damage} + for boss in [*list(range(8)), 12]} + flexibility = [(sum(1 if weapon_boss[boss][weapon] > 0 else 0 for weapon in range(9)) * + sum(weapon_boss[boss].values()), boss) for boss in weapon_boss if boss != 12] + for _, boss in [*sorted(flexibility), (0, 12)]: + boss_damage = weapon_boss[boss] + weapon_weight = {weapon: (weapon_energy[weapon] / damage) if damage else 0 for weapon, damage in + boss_damage.items() if weapon_energy[weapon]} + if any(boss_damage[i] > 0 for i in range(8)) and 8 in weapon_weight: + # We get exactly one use of Time Stopper during the rush + # So we want to make sure that use is absolutely needed + weapon_weight[8] = min(weapon_weight[8], 0.001) + while boss_health[boss] > 0: + if boss_damage[0]: + boss_health[boss] = 0 # if we can buster, we should buster + continue + highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys())) + uses = weapon_energy[wp] // weapon_costs[wp] + if int(uses * boss_damage[wp]) > boss_health[boss]: + used = ceil(boss_health[boss] / boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * used + boss_health[boss] = 0 + elif highest <= 0: + # we are out of weapons that can actually damage the boss + base.fail(f"Ran out of weapon energy to damage " + f"{next(name for name in bosses if bosses[name] == boss)}\n" + f"Seed: {base.multiworld.seed}\n" + f"Damage Table: {weapon_damage}") + else: + # drain the weapon and continue + boss_health[boss] -= int(uses * boss_damage[wp]) + weapon_energy[wp] -= weapon_costs[wp] * uses + weapon_weight.pop(wp) + + +class StrictWeaknessTests(MM2TestBase): + options = { + "strict_weakness": True, + "yoku_jumps": True, + "enable_lasers": True + } + + def test_that_every_boss_has_a_weakness(self) -> None: + world = self.multiworld.worlds[self.player] + weapon_damage = world.weapon_damage + for boss in range(14): + if not any(weapon_damage[weapon][boss] for weapon in range(9)): + self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}") + + def test_wily_5(self) -> None: + validate_wily_5(self) + + +class RandomStrictWeaknessTests(MM2TestBase): + options = { + "strict_weakness": True, + "random_weakness": "randomized", + "yoku_jumps": True, + "enable_lasers": True + } + + def test_that_every_boss_has_a_weakness(self) -> None: + world = self.multiworld.worlds[self.player] + weapon_damage = world.weapon_damage + for boss in range(14): + if not any(weapon_damage[weapon][boss] for weapon in range(9)): + self.fail(f"Boss {boss} generated without weakness! Seed: {self.multiworld.seed}") + + def test_wily_5(self) -> None: + validate_wily_5(self) diff --git a/worlds/mm2/text.py b/worlds/mm2/text.py new file mode 100644 index 000000000000..32d665bf6c7f --- /dev/null +++ b/worlds/mm2/text.py @@ -0,0 +1,90 @@ +from typing import DefaultDict +from collections import defaultdict + +MM2_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda x: 0x6F, { + ' ': 0x40, + 'A': 0x41, + 'B': 0x42, + 'C': 0x43, + 'D': 0x44, + 'E': 0x45, + 'F': 0x46, + 'G': 0x47, + 'H': 0x48, + 'I': 0x49, + 'J': 0x4A, + 'K': 0x4B, + 'L': 0x4C, + 'M': 0x4D, + 'N': 0x4E, + 'O': 0x4F, + 'P': 0x50, + 'Q': 0x51, + 'R': 0x52, + 'S': 0x53, + 'T': 0x54, + 'U': 0x55, + 'V': 0x56, + 'W': 0x57, + 'X': 0x58, + 'Y': 0x59, + 'Z': 0x5A, + # 0x5B is the small r in Dr Light + '.': 0x5C, + ',': 0x5D, + '\'': 0x5E, + '!': 0x5F, + '(': 0x60, + ')': 0x61, + '#': 0x62, + '$': 0x63, + '%': 0x64, + '&': 0x65, + '*': 0x66, + '+': 0x67, + '/': 0x68, + '\\': 0x69, + ':': 0x6A, + ';': 0x6B, + '<': 0x6C, + '>': 0x6D, + '=': 0x6E, + '?': 0x6F, + '@': 0x70, + '[': 0x71, + ']': 0x72, + '^': 0x73, + '_': 0x74, + '`': 0x75, + '{': 0x76, + '}': 0x77, + '|': 0x78, + '~': 0x79, + '\"': 0x92, + '-': 0x94, + '0': 0xA0, + '1': 0xA1, + '2': 0xA2, + '3': 0xA3, + '4': 0xA4, + '5': 0xA5, + '6': 0xA6, + '7': 0xA7, + '8': 0xA8, + '9': 0xA9, +}) + + +class MM2TextEntry: + def __init__(self, text: str = "", coords: int = 0x0B): + self.target_area: int = 0x25 # don't change + self.coords: int = coords # 0xYX, Y can only be increments of 0x20 + self.text: str = text + + def resolve(self) -> bytes: + data = bytearray() + data.append(self.target_area) + data.append(self.coords) + data.extend([MM2_WEAPON_ENCODING[x] for x in self.text.upper()]) + data.extend([0x40] * (14 - len(self.text))) + return bytes(data)