diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index 0e368386c54f..664e477bbfe0 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -25,6 +25,9 @@ # Aquaria /worlds/aquaria/ @tioui +# Autopelago +/worlds/autopelago/ @airbreather + # Blasphemous /worlds/blasphemous/ @TRPG0 diff --git a/worlds/autopelago/AutopelagoDefinitions.yml b/worlds/autopelago/AutopelagoDefinitions.yml new file mode 100644 index 000000000000..fbf677431dad --- /dev/null +++ b/worlds/autopelago/AutopelagoDefinitions.yml @@ -0,0 +1,870 @@ +# This file exists in both the client and the server projects. +# Please make sure that it matches in both places. +items: + moon_shoes: + name: 'Moon Shoes' + flavor_text: 'All done here, let''s bounce.' + mongoose_in_a_combat_spacecraft: + name: 'Mongoose in a Combat Spacecraft' + flavor_text: 'Do an aileron roll!' + quantum_sugar_cube: + name: 'Quantum Sugar Cube' + flavor_text: 'Improbably sweet!' + playing_with_fire_for_dummies: + name: 'Playing with Fire For Dummies' + flavor_text: 'Required reading for any hotshot.' + moon_shaped_like_a_butt: + name: 'Moon Shaped Like a Butt' + flavor_text: 'haha' + asteroid_belt: + name: 'Asteroid Belt' + flavor_text: 'I prefer asteroid suspenders, myself.' + free_vowel: + name: 'Free Vowel' + flavor_text: 'A $250 value!' + constellation_prize: + name: 'Constellation Prize' + flavor_text: 'Please not Leo, please not Leo...' + foreign_coin: + name: 'Foreign Coin' + flavor_text: 'I wonder where I can spend it...' + auras_granted: [lucky] + red_matador_cape: + name: 'Red Matador''s Cape' + flavor_text: 'For not taking the bull by the horns.' + pharaoh_not_anti_mummy_spray: + name: 'Pharaoh-Not Anti-Mummy Spray' + flavor_text: 'Curse-free or your money back!' + auras_granted: [confident] + pile_of_scrap_metal_in_the_shape_of_a_rocket_ship: + name: 'Pile of Scrap Metal in the Shape of a Rocket Ship' + flavor_text: 'How incredibly convenient!' + energy_drink_that_is_pure_rocket_fuel: + name: 'Energy Drink that is Pure Rocket Fuel' + flavor_text: 'Not safe for rodent consumption...' + turbo_encabulator: + name: 'Turbo Encabulator' + flavor_text: 'Effectively prevents side-fumbling!' + map_of_the_entire_internet: + name: 'Map of the Entire Internet' + flavor_text: 'Should have taken a left at 104.20.61.247!' + virtual_key: + name: 'Virtual Key' + flavor_text: 'Unlocks virtually every lock!' + banana_peel: + name: 'Banana Peel' + flavor_text: 'Ripe for causing chaos.' + blue_turtle_shell: + name: 'Blue Turtle Shell' + flavor_text: 'A street-seeking missile.' + forklift_certification: + name: 'Forklift Certification' + flavor_text: 'Essential for having a CRATE time.' + fifty_cents: + name: 'Fifty Cents' + flavor_text: 'Two packs of M&Ms cost fifty cents? That''s ludicrous!' + childs_first_hand_axe: + name: 'Child''s First Hand Axe' + flavor_text: 'Good for left-handed and right-handed children!' + hammer_of_problem_solving: + name: 'Hammer of Problem-Solving' + flavor_text: 'Either way, the screen won''t be blue anymore.' + lost_ctrl_key: + name: 'Lost CTRL Key' + flavor_text: 'The ALT path to DELETE the problem.' + artificial_grass: + name: 'Artificial Grass' + flavor_text: 'Grown from just a random seed.' + macguffin: + name: 'MacGuffin' + flavor_text: 'I''m sure SOMEBODY''s looking for this...' + masterful_longsword: + name: 'Masterful Longsword' + flavor_text: 'This sword looks dangerous. Best leave it alone.' + legally_binding_contract: + name: 'Legally Binding Contract' + flavor_text: 'The counsel will decide your fate.' + fake_mouse_ears: + name: 'Fake Mouse Ears' + flavor_text: 'Needed to sneak into his clubhouse.' + giant_novelty_scissors: + name: 'Giant Novelty Scissors' + flavor_text: 'The natural predator of the giant novelty check.' + premium_can_of_prawn_food: + name: 'Premium Can of Prawn Food' + flavor_text: '20% more plankton than the leading brand!' + priceless_antique: + name: 'Priceless Antique' + flavor_text: 'Best I can do is five bucks.' + pack_rat: + name: 'Pack Rat' + rat_count: 1 + rat_pack: + name: 'Rat Pack' + rat_count: 5 + flavor_text: 'Five is the canonical number of rats in a Rat Pack.' + pizza_rat: + name: 'Pizza Rat' + rat_count: 1 + flavor_text: 'He''s living his best life.' + chef_rat: + name: 'Chef Rat' + rat_count: 1 + flavor_text: 'Shh... there''s a human under his hat.' + ninja_rat: + name: 'Ninja Rat' + rat_count: 1 + flavor_text: 'Take ''em out? Sure I can!' + gym_rat: + name: 'Gym Rat' + rat_count: 1 + flavor_text: 'They call him Monterey, because he''s JACKed.' + computer_rat: + name: 'Computer Rat' + rat_count: 1 + flavor_text: 'Obviously superior to the computer mouse.' + pie_rat: + name: 'Pie Rat' + rat_count: 1 + flavor_text: 'But you HAVE heard of him?' + ziggu_rat: + name: 'Ziggu Rat' + rat_count: 1 + flavor_text: 'WANTED: Roommate. Dead or alive.' + acro_rat: + name: 'Acro Rat' + rat_count: 1 + flavor_text: 'PARKOUR!' + lab_rat: + name: 'Lab Rat' + rat_count: 1 + flavor_text: 'One more maze and he gets a free coffee.' + ratstronaut: + name: 'Ratstronaut' + rat_count: 1 + flavor_text: 'She understands the gravity of the situation.' + notorious_r_a_t: + name: 'Notorious R.A.T.' + rat_count: 1 + flavor_text: 'I gotta spit some rhymes? No biggie.' + + +# ability_check_with_dc is intended to make intuitive +# sense to those who are familiar with the basic concept outlined on: +# https://www.5esrd.com/using-ability-scores#Ability_Checks so we can make the +# game "feel" more "difficult" as the "player" enters the later stages. +regions: + landmarks: + snakes_on_a_planet: + name: 'Snakes on a Planet' + flavor_text: 'There sure are a heckin'' lot of snakes on this heckin'' planet...' + unrandomized_item: moon_shoes + requires: + item: mongoose_in_a_combat_spacecraft + ability_check_dc: 24 + + asteroid_with_pants: + name: 'Asteroid with Pants' + flavor_text: 'Don''t you hate it when your pants are a million sizes too big?' + unrandomized_item: pack_rat + requires: + any: + - item: asteroid_belt + - item: moon_shaped_like_a_butt + ability_check_dc: 26 + exits: [] + + minotaur_labyrinth: + name: 'Minotaur Labyrinth' + flavor_text: 'No worries, minotaur, we get lost in mazes all the time!' + unrandomized_item: pack_rat + requires: + any: + - item: red_matador_cape + - item: lab_rat + ability_check_dc: 23 + exits: + - before_asteroid_with_pants + - after_minotaur_labyrinth + + space_opera: + name: 'Space Opera' + flavor_text: 'It''s not over until the supermassive black hole sings!' + unrandomized_item: pack_rat + requires: + rat_count: 49 + ability_check_dc: 21 + exits: + - after_space_opera + + seal_of_fortune: + name: 'Seal of Fortune' + flavor_text: 'Should I pick knowledge and understanding of the limitless secrets of the universe, or... door number 2?' + unrandomized_item: pack_rat + requires: + any: + - item: constellation_prize + - item: free_vowel + ability_check_dc: 23 + exits: + - before_minotaur_labyrinth + - before_space_opera + + alien_vending_machine: + name: 'Alien Vending Machine' + flavor_text: 'Surprisingly, the aliens have all the same soda brands as us!' + unrandomized_item: pack_rat + requires: + item: foreign_coin + ability_check_dc: 26 + exits: [] + + frozen_assets: + name: 'Frozen Assets' + flavor_text: 'Frost-benefit analysis says to keep some of it on ice.' + unrandomized_item: pack_rat + requires: + item: playing_with_fire_for_dummies + ability_check_dc: 26 + exits: [] + + homeless_mummy: + name: 'Homeless Mummy' + flavor_text: 'Those pyramid schemes will ruin you...' + unrandomized_item: free_vowel + requires: + any: + - item: ziggu_rat + - item: pharaoh_not_anti_mummy_spray + ability_check_dc: 22 + exits: + - before_frozen_assets + - before_alien_vending_machine + - after_homeless_mummy + + stalled_rocket_get_out_and_push: + name: 'Stalled Rocket' + flavor_text: 'Blast it! Someone will have to get out and push...' + unrandomized_item: constellation_prize + requires: + rat_count: 40 + ability_check_dc: 22 + exits: + - after_stalled_rocket_get_out_and_push + + roboclop_the_robot_war_horse: + name: 'Robo-Clop: The Robot War Horse' + flavor_text: 'Ever since the operation, he''s been a bit un-STABLE.' + unrandomized_item: pack_rat + requires: + item: quantum_sugar_cube + ability_check_dc: 21 + exits: + - before_stalled_rocket_get_out_and_push + - before_homeless_mummy + + makeshift_rocket_ship: + name: 'Makeshift Rocket Ship' + flavor_text: 'It''s actually WAY easier than brain surgery.' + unrandomized_item: pack_rat + requires: + any_two: + - item: turbo_encabulator + - item: ratstronaut + - item: energy_drink_that_is_pure_rocket_fuel + - item: pile_of_scrap_metal_in_the_shape_of_a_rocket_ship + ability_check_dc: 19 + exits: + - before_roboclop_the_robot_war_horse + + secret_cache: + name: 'Secret Cache' + flavor_text: 'How secret can it be if it''s on my map right here?' + unrandomized_item: pack_rat + requires: + any: + - item: virtual_key + - item: map_of_the_entire_internet + ability_check_dc: 21 + exits: + - before_makeshift_rocket_ship + + rat_rap_battle: + name: 'Rat Rap Battle' + flavor_text: 'Do they REALLY spit fire? That sounds dangerous...' + unrandomized_item: pack_rat + requires: + any: + - item: fifty_cents + - item: notorious_r_a_t + ability_check_dc: 19 + exits: + - after_rat_rap_battle + + stack_of_crates: + name: 'Stack of Crates' + flavor_text: 'What do you mean, "just walk around them"?' + unrandomized_item: pack_rat + requires: + any: + - item: gym_rat + - item: forklift_certification + ability_check_dc: 19 + exits: + - after_stack_of_crates + + binary_tree: + name: 'Binary Tree' + flavor_text: 'The branches here are full of juicy pairs.' + unrandomized_item: pack_rat + requires: + item: childs_first_hand_axe + ability_check_dc: 19 + exits: + - before_rat_rap_battle + + room_full_of_typewriters: + name: 'Room Full of Typewriters' + flavor_text: 'I''m sure this room has a lot of KEY items!' + unrandomized_item: pack_rat + requires: + rat_count: 37 + ability_check_dc: 25 + exits: [] + + trapeze: + name: 'Trapeze' + flavor_text: 'Let''s dino-SOAR to victory!' + unrandomized_item: pack_rat + requires: + item: acro_rat + ability_check_dc: 24 + exits: [] + + computer_ram: + name: 'Computer Ram' + flavor_text: 'Short-term memory, long-term damage.' + unrandomized_item: pack_rat + requires: + item: artificial_grass + ability_check_dc: 19 + exits: + - before_stack_of_crates + + copyright_mouse: + name: 'Copyright Mouse' + flavor_text: 'The locations depicted in this game are entirely fictitious. Any similarity to real-world names or properties is entirely coincidental.' + unrandomized_item: pack_rat + requires: + any: + - item: fake_mouse_ears + - item: legally_binding_contract + ability_check_dc: 18 + exits: + - before_room_full_of_typewriters + - before_binary_tree + + blue_colored_screen_interface: + name: 'Blue-Colored Screen Interface' + flavor_text: 'It works just fine on my machine...' + unrandomized_item: pack_rat + requires: + any: + - item: lost_ctrl_key + - item: hammer_of_problem_solving + ability_check_dc: 18 + exits: + - before_computer_ram + - before_trapeze + + broken_down_bus: + name: 'Broken-Down Bus' + flavor_text: 'The driver made an error and crashed, and now it''s blocking traffic!' + unrandomized_item: pack_rat + requires: + rat_count: 25 + ability_check_dc: 19 + exits: + - before_copyright_mouse + + overweight_boulder: + name: 'Overweight Boulder' + flavor_text: 'Aww, it''s not overweight. It''s just big-stoned.' + unrandomized_item: pack_rat + requires: + rat_count: 25 + ability_check_dc: 19 + exits: + - before_blue_colored_screen_interface + + kart_races: + name: 'Kart Races' + flavor_text: 'One of the more dangerous rat races out there.' + unrandomized_item: pack_rat + requires: + any: + - item: blue_turtle_shell + - item: banana_peel + ability_check_dc: 17 + exits: + - before_broken_down_bus + + daring_adventurer: + name: 'Daring Adventurer' + flavor_text: 'A kid with a sword. The mortal enemy of a rat.' + unrandomized_item: pack_rat + requires: + any: + - item: masterful_longsword + - item: macguffin + ability_check_dc: 17 + exits: + - before_overweight_boulder + + computer_interface: + name: 'Computer Interface' + flavor_text: 'It would take quite the hack rat to crack this code!' + unrandomized_item: pack_rat + requires: + item: computer_rat + ability_check_dc: 16 + exits: + - before_daring_adventurer + - before_kart_races + + captured_goldfish: + name: 'Captured Goldfish' + flavor_text: 'Fell for it. Hook, line, and sinker.' + unrandomized_item: pack_rat + requires: + item: giant_novelty_scissors + ability_check_dc: 16 + exits: + - before_computer_interface + + bowling_ball_door: + name: 'Bowling Ball Door' + flavor_text: 'Another gutter, another ball.' + unrandomized_item: rat_pack + requires: + rat_count: 10 + ability_check_dc: 14 + exits: + - before_captured_goldfish + + restaurant: + name: 'Restaurant' + flavor_text: 'It''s surprisingly accommodating, considering...' + unrandomized_item: pack_rat + requires: + item: chef_rat + ability_check_dc: 14 + exits: + - after_restaurant + + pirate_bake_sale: + name: 'Pirate Bake Sale' + flavor_text: 'Better than being stranded on a DESSERTed island!' + unrandomized_item: pack_rat + requires: + item: pie_rat + ability_check_dc: 14 + exits: + - after_pirate_bake_sale + + angry_turtles: + name: 'Angry Turtles' + flavor_text: 'Blocking this pipe is so NOT cowabunga, dude!' + unrandomized_item: pack_rat + requires: + any: + - item: pizza_rat + - item: ninja_rat + ability_check_dc: 13 + exits: + - before_restaurant + + prawn_stars: + name: 'Prawn Stars' + flavor_text: 'It''s not shellfish. It''s just business.' + unrandomized_item: pack_rat + requires: + any: + - item: premium_can_of_prawn_food + - item: priceless_antique + ability_check_dc: 12 + exits: + - before_pirate_bake_sale + + basketball: + name: 'Basketball' + flavor_text: 'There''s no rule that says you can''t...' + unrandomized_item: pack_rat + requires: + rat_count: 5 + ability_check_dc: 10 + exits: + - before_prawn_stars + - before_angry_turtles + + fillers: + after_minotaur_labyrinth: + name_template: 'After Minotaur Labyrinth #{n}' + unrandomized_items: + key: + - mongoose_in_a_combat_spacecraft + filler: 3 + useful_nonprogression: 2 + exits: + - snakes_on_a_planet + + after_space_opera: + name_template: 'After Space Opera #{n}' + unrandomized_items: + filler: 5 + useful_nonprogression: 2 + exits: + - snakes_on_a_planet + + before_asteroid_with_pants: + name_template: 'Before Asteroid with Pants #{n}' + unrandomized_items: + key: + - asteroid_belt + - moon_shaped_like_a_butt + filler: 1 + useful_nonprogression: 1 + ability_check_dc: 23 + exits: + - asteroid_with_pants + + before_space_opera: + name_template: 'Before Space Opera #{n}' + unrandomized_items: + filler: 4 + useful_nonprogression: 2 + exits: + - space_opera + + before_minotaur_labyrinth: + name_template: 'Before Minotaur Labyrinth #{n}' + unrandomized_items: + key: + - red_matador_cape + - lab_rat + filler: 1 + useful_nonprogression: 2 + exits: + - minotaur_labyrinth + + before_alien_vending_machine: + name_template: 'Before Alien Vending Machine #{n}' + unrandomized_items: + key: + - foreign_coin + filler: 1 + useful_nonprogression: 2 + ability_check_dc: 23 + exits: + - alien_vending_machine + + before_frozen_assets: + name_template: 'Before Frozen Assets #{n}' + unrandomized_items: + key: + - playing_with_fire_for_dummies + filler: 4 + useful_nonprogression: 2 + ability_check_dc: 23 + exits: + - frozen_assets + + after_homeless_mummy: + name_template: 'After Homeless Mummy #{n}' + unrandomized_items: + filler: 4 + useful_nonprogression: 3 + exits: + - seal_of_fortune + + after_stalled_rocket_get_out_and_push: + name_template: 'After Stalled Rocket! #{n}' + unrandomized_items: + filler: 4 + useful_nonprogression: 3 + exits: + - seal_of_fortune + + before_homeless_mummy: + name_template: 'Before Homeless Mummy #{n}' + unrandomized_items: + key: + - pharaoh_not_anti_mummy_spray + - ziggu_rat + filler: 2 + useful_nonprogression: 2 + exits: + - homeless_mummy + + before_stalled_rocket_get_out_and_push: + name_template: 'Before Stalled Rocket! #{n}' + unrandomized_items: + filler: 4 + useful_nonprogression: 2 + exits: + - stalled_rocket_get_out_and_push + + before_roboclop_the_robot_war_horse: + name_template: 'Before Robo-Clop: The Robot War Horse #{n}' + unrandomized_items: + key: + - quantum_sugar_cube + filler: 16 + useful_nonprogression: 8 + exits: + - roboclop_the_robot_war_horse + + before_makeshift_rocket_ship: + name_template: 'Before Makeshift Rocket Ship #{n}' + unrandomized_items: + key: + - turbo_encabulator + - ratstronaut + - energy_drink_that_is_pure_rocket_fuel + - pile_of_scrap_metal_in_the_shape_of_a_rocket_ship + filler: 16 + useful_nonprogression: 8 + exits: + - makeshift_rocket_ship + + after_rat_rap_battle: + name_template: 'After Rat Rap Battle #{n}' + unrandomized_items: + key: + - virtual_key + filler: 4 + useful_nonprogression: 2 + exits: + - secret_cache + + after_stack_of_crates: + name_template: 'After Stack of Crates #{n}' + unrandomized_items: + key: + - map_of_the_entire_internet + filler: 4 + useful_nonprogression: 2 + exits: + - secret_cache + + before_rat_rap_battle: + name_template: 'Before Rat Rap Battle #{n}' + unrandomized_items: + key: + - fifty_cents + - notorious_r_a_t + filler: 2 + useful_nonprogression: 2 + exits: + - rat_rap_battle + + before_stack_of_crates: + name_template: 'Before Stack of Crates #{n}' + unrandomized_items: + key: + - gym_rat + - forklift_certification + filler: 2 + useful_nonprogression: 2 + exits: + - stack_of_crates + + before_binary_tree: + name_template: 'Before Binary Tree #{n}' + unrandomized_items: + key: + - childs_first_hand_axe + filler: 2 + useful_nonprogression: 2 + exits: + - binary_tree + + before_room_full_of_typewriters: + name_template: 'Before Room Full of Typewriters #{n}' + unrandomized_items: + filler: 4 + useful_nonprogression: 2 + ability_check_dc: 20 + exits: + - room_full_of_typewriters + + before_trapeze: + name_template: 'Before Trapeze #{n}' + unrandomized_items: + key: + - acro_rat + filler: 3 + useful_nonprogression: 2 + ability_check_dc: 20 + exits: + - trapeze + + before_computer_ram: + name_template: 'Before Computer Ram #{n}' + unrandomized_items: + key: + - artificial_grass + filler: 4 + useful_nonprogression: 2 + exits: + - computer_ram + + before_copyright_mouse: + name_template: 'Before Copyright Mouse #{n}' + unrandomized_items: + key: + - fake_mouse_ears + - legally_binding_contract + filler: 4 + useful_nonprogression: 2 + exits: + - copyright_mouse + + before_blue_colored_screen_interface: + name_template: 'Before Blue-Colored Screen Interface #{n}' + unrandomized_items: + key: + - lost_ctrl_key + - hammer_of_problem_solving + filler: 1 + useful_nonprogression: 2 + exits: + - blue_colored_screen_interface + + before_broken_down_bus: + name_template: 'Before Broken Down Bus #{n}' + unrandomized_items: + filler: 4 + useful_nonprogression: 2 + exits: + - broken_down_bus + + before_overweight_boulder: + name_template: 'Before Overweight Boulder #{n}' + unrandomized_items: + filler: 4 + useful_nonprogression: 2 + exits: + - overweight_boulder + + before_kart_races: + name_template: 'Before Kart Races #{n}' + unrandomized_items: + key: + - blue_turtle_shell + - banana_peel + filler: 4 + useful_nonprogression: 2 + exits: + - kart_races + + before_daring_adventurer: + name_template: 'Before Daring Adventurer #{n}' + unrandomized_items: + key: + - masterful_longsword + - macguffin + filler: 4 + useful_nonprogression: 2 + exits: + - daring_adventurer + + before_computer_interface: + name_template: 'Before Computer Interface #{n}' + unrandomized_items: + key: + - computer_rat + filler: 14 + useful_nonprogression: 2 + exits: + - computer_interface + + before_captured_goldfish: + name_template: 'Before Goldfish #{n}' + unrandomized_items: + key: + - giant_novelty_scissors + useful_nonprogression: 2 + filler: 4 + exits: + - captured_goldfish + + after_restaurant: + name_template: 'After Restaurant #{n}' + unrandomized_items: + useful_nonprogression: 16 + filler: 15 + exits: + - bowling_ball_door + + after_pirate_bake_sale: + name_template: 'After Pirate Bake Sale #{n}' + unrandomized_items: + useful_nonprogression: 8 + filler: 6 + exits: + - bowling_ball_door + + before_restaurant: + name_template: 'Before Restaurant #{n}' + unrandomized_items: + key: + - chef_rat + useful_nonprogression: 2 + filler: 6 + exits: + - restaurant + + before_pirate_bake_sale: + name_template: 'Before Pirate Bake Sale #{n}' + unrandomized_items: + key: + - pie_rat + - pack_rat + useful_nonprogression: 2 + filler: 6 + exits: + - pirate_bake_sale + + before_angry_turtles: + name_template: 'Before Angry Turtles #{n}' + unrandomized_items: + key: + - pizza_rat + - ninja_rat + useful_nonprogression: 2 + filler: 6 + exits: + - angry_turtles + + before_prawn_stars: + name_template: 'Before Prawn Stars #{n}' + unrandomized_items: + key: + - premium_can_of_prawn_food + - priceless_antique + useful_nonprogression: 2 + filler: 6 + exits: + - prawn_stars + + before_basketball: + name_template: 'Before Basketball #{n}' + unrandomized_items: + key: + - item: pack_rat + count: 5 + useful_nonprogression: 5 + filler: 16 + exits: + - basketball diff --git a/worlds/autopelago/__init__.py b/worlds/autopelago/__init__.py new file mode 100644 index 000000000000..71265cda08f8 --- /dev/null +++ b/worlds/autopelago/__init__.py @@ -0,0 +1,416 @@ +import logging +import typing +from collections import deque +from collections.abc import Callable, Iterable +from typing import TypeVar + +from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region, Tutorial +from Options import OptionGroup +from worlds.AutoWorld import WebWorld, World + +from .definitions_types import ( + Aura, + AutopelagoAllRequirement, + AutopelagoAnyRequirement, + AutopelagoAnyTwoRequirement, + AutopelagoGameRequirement, + AutopelagoItemRequirement, + AutopelagoNonProgressionItemType, + AutopelagoRatCountRequirement, + AutopelagoRegionDefinition, +) +from .items import ( + ENABLED_AURA_SCORE_THRESHOLD, + classify_item_by_score, + item_name_to_auras, + item_name_to_id, + items_by_game, + lactose_intolerant_names, + names_with_lactose, + nonprogression_item_types, + progression_item_names, + score_item_by_auras, +) +from .locations import ( + autopelago_regions, + item_key_to_name, + item_name_groups, + item_name_to_rat_count, + location_name_groups, + location_name_to_id, + location_name_to_nonprogression_item, + location_name_to_progression_item_name, + location_name_to_requirement, + max_required_rat_count, + total_available_rat_count, +) +from .options import ( + AutopelagoGameOptions, + ChangedTargetMessages, + CompleteGoalMessages, + EnabledBuffs, + EnabledTraps, + EnterBKModeMessages, + EnterGoModeMessages, + ExitBKModeMessages, + RemindBKModeMessages, + VictoryLocation, +) +from .util import GAME_NAME + +autopelago_logger = logging.getLogger(GAME_NAME) +T = TypeVar("T") + + +def _is_trivial(req: AutopelagoGameRequirement): + if "all" in req: + return not req["all"] + if "rat_count" in req: + return req["rat_count"] == 0 + return False + + +def _is_satisfied(player: int, req: AutopelagoGameRequirement, state: CollectionState): + if "all" in req: + req: AutopelagoAllRequirement + return all(_is_satisfied(player, sub_req, state) for sub_req in req["all"]) + if "any" in req: + req: AutopelagoAnyRequirement + return any(_is_satisfied(player, sub_req, state) for sub_req in req["any"]) + if "any_two" in req: + req: AutopelagoAnyTwoRequirement + return sum(1 if _is_satisfied(player, sub_req, state) else 0 for sub_req in req["any_two"]) > 1 + if "item" in req: + req: AutopelagoItemRequirement + return state.has(item_key_to_name[req["item"]], player) + assert "rat_count" in req, "Only AutopelagoRatCountRequirement is expected here" + req: AutopelagoRatCountRequirement + return sum(item_name_to_rat_count[k] * i for k, i in state.prog_items[player].items() if + k in item_name_to_rat_count) >= req["rat_count"] + + +class AutopelagoItem(Item): + game = GAME_NAME + + +class AutopelagoLocation(Location): + game = GAME_NAME + + def __init__(self, player: int, name: str, parent: Region): + super().__init__(player, name, location_name_to_id[name] if name in location_name_to_id else None, parent) + if name in location_name_to_requirement: + req = location_name_to_requirement[name] + if not _is_trivial(req): + self.access_rule = lambda state: _is_satisfied(player, req, state) + + +class AutopelagoRegion(Region): + game = GAME_NAME + autopelago_definition: AutopelagoRegionDefinition + + def __init__(self, autopelago_definition: AutopelagoRegionDefinition, player: int, multiworld: MultiWorld, + hint: str | None = None): + super().__init__(autopelago_definition.key, player, multiworld, hint) + self.autopelago_definition = autopelago_definition + self.locations += (AutopelagoLocation(player, loc, self) for loc in autopelago_definition.locations) + + +class AutopelagoWebWorld(WebWorld): + theme = "partyTime" + rich_text_options_doc = True + tutorials: typing.ClassVar[list[Tutorial]] = [Tutorial( + tutorial_name="Setup Guide", + description="A guide to playing Autopelago", + language="English", + file_name="setup_en.md", + link="guide/en", + authors=["airbreather"] + )] + + +class AutopelagoWorld(World): + """ + Autopelago is a game that plays itself, built specifically to help practice or bulk out your Archipelago multiworld. + It sends location checks automatically based on customizable time-based intervals and runs entirely in your browser. + """ + game = GAME_NAME + topology_present = False # it's static, so setting this to True isn't actually helpful + origin_region_name = "before_basketball" + web = AutopelagoWebWorld() + options_dataclass = AutopelagoGameOptions + options: AutopelagoGameOptions + victory_location: str + regions_in_scope: set[str] + locations_in_scope: set[str] + enabled_auras: set[Aura] + enabled_auras_by_item_id: dict[int, list[Aura]] + option_groups: typing.ClassVar[list[OptionGroup]] = [ + OptionGroup("Message Text Replacements", [ + ChangedTargetMessages, + EnterGoModeMessages, + EnterBKModeMessages, + RemindBKModeMessages, + ExitBKModeMessages, + CompleteGoalMessages, + ]), + ] + + # item_name_to_id and location_name_to_id must be filled VERY early. don't get any ideas about + # having the user's YAML file dynamically create new items / locations or anything like that. of + # course, we could still theoretically pre-generate arbitrarily many filler location names if we + # want to introduce *some* configurability there. + item_name_to_id = item_name_to_id + location_name_to_id = location_name_to_id + item_name_groups = item_name_groups + location_name_groups = location_name_groups + + def __init__(self, multiworld, player): + self.enabled_auras = set() + super().__init__(multiworld, player) + + # insert other ClassVar values... suggestions include: + # - item_descriptions + # - location_descriptions + # - hint_blacklist (should it include the goal item?) + + def generate_early(self): + self.enabled_auras.clear() + for aura in self.options.enabled_buffs.value: + self.enabled_auras.add(EnabledBuffs.map[aura]) + for aura in self.options.enabled_traps.value: + self.enabled_auras.add(EnabledTraps.map[aura]) + self.enabled_auras_by_item_id = {} + for name, auras in item_name_to_auras.items(): + enabled_auras = [aura for aura in auras if aura in self.enabled_auras] + if enabled_auras: + self.enabled_auras_by_item_id[item_name_to_id[name]] = enabled_auras + + match self.options.victory_location: + case VictoryLocation.option_captured_goldfish: + self.victory_location = "Captured Goldfish" + case VictoryLocation.option_secret_cache: + self.victory_location = "Secret Cache" + case _: + self.victory_location = "Snakes on a Planet" + + # work out how many locations are in scope for this playthrough so that create_items can see + # which progression items are required and which regions are in scope so that we can filter + # which ones to generate Archipelago stuff for. there isn't a way to play the "unrandomized" + # version of the game, but there's still some sort of logic to where the definitions file + # places such "unrandomized" items, exactly to enable this kind of thing. + self.locations_in_scope = set() + q = deque((self.origin_region_name,)) + self.regions_in_scope = {self.origin_region_name,} + while q: + r = autopelago_regions[q.popleft()] + locations_set = set(r.locations) + self.locations_in_scope.update(locations_set) + if self.victory_location in locations_set: + # don't go beyond the victory location (all "exits" are considered "forward") + continue + for next_exit in r.exits: + if next_exit in self.regions_in_scope: + continue + self.regions_in_scope.add(next_exit) + q.append(next_exit) + + def create_item(self, name: str): + item_id = item_name_to_id[name] + classification = \ + ItemClassification.progression if name in progression_item_names else \ + classify_item_by_score(score_item_by_auras(name, self.enabled_auras)) + return AutopelagoItem(name, classification, item_id, self.player) + + def create_items(self): + new_items = [self.create_item(item) + for location, item in location_name_to_progression_item_name.items() + if location in self.locations_in_scope and item != "Moon Shoes"] + + # skip balancing for the pack_rat items that take us beyond the minimum limit + rat_items = sorted( + (item for item in new_items if item.name in item_name_to_rat_count), + key=lambda item: (item_name_to_rat_count[item.name], 0 if item.name == item_key_to_name["pack_rat"] else 1) + ) + for i in range(total_available_rat_count - max_required_rat_count): + assert rat_items[i].name == item_key_to_name["pack_rat"],\ + "Expected there to be enough pack_rat fillers for this calculation." + rat_items[i].classification |= ItemClassification.skip_balancing + # deprioritize ALL pack_rat items. + for item in rat_items: + if item.name == item_key_to_name["pack_rat"]: + item.classification |= ItemClassification.deprioritized + + self.multiworld.itempool += new_items + excluded_names: set[str] = set( + names_with_lactose if self.options.lactose_intolerant.value else + lactose_intolerant_names + ) + + # nonprogression items are tricky. even just picking an item to fill a slot that's marked as + # a buff/trap/filler is nontrivial because buffs and traps can be disabled arbitrarily. + item_pools = { k: self._sort_nonprogression_items_for_item_type(k) for k in nonprogression_item_types } + next_item_indices = dict.fromkeys(nonprogression_item_types, 0) + + # none of the "unrandomized" items at our locations actually say "trap"; instead, half of + # the time we're asked for a "filler", we actually mean "trap" instead. + next_filler_becomes_trap = False + for loc, original_item_type in location_name_to_nonprogression_item.items(): + if loc not in self.locations_in_scope: + continue + + item_type = original_item_type + if item_type == "filler": + if next_filler_becomes_trap: + item_type: AutopelagoNonProgressionItemType = "trap" + next_filler_becomes_trap = not next_filler_becomes_trap + + item_pool = item_pools[item_type] + next_item_index = next_item_indices[item_type] + while next_item_index < len(item_pool): + next_item = item_pool[next_item_index] + next_item_index += 1 + if next_item in excluded_names: + continue + self.multiworld.itempool.append(self.create_item(next_item)) + excluded_names.add(next_item) + next_item_indices[item_type] = next_item_index + break + + def create_regions(self): + victory_region = Region("Victory", self.player, self.multiworld) + self.multiworld.regions.append(victory_region) + self.multiworld.completion_condition[self.player] =\ + lambda state: state.can_reach(victory_region) + + new_regions = {r.key: AutopelagoRegion(r, self.player, self.multiworld) + for key, r in autopelago_regions.items() + if key in self.regions_in_scope} + for r in new_regions.values(): + self.multiworld.regions.append(r) + req = r.autopelago_definition.requires + rule: Callable[[CollectionState], bool] | None + # disable PLC3002: the lambda must use the CURRENT value of 'req', so it needs a new scope somehow. + rule = None if _is_trivial(req) \ + else (lambda req_: lambda state: _is_satisfied(self.player, req_, state))(req) # noqa: PLC3002 + if self.victory_location in r.autopelago_definition.locations: + r.connect(victory_region, rule=rule) + if self.options.victory_location == VictoryLocation.option_snakes_on_a_planet: + r.locations[0].place_locked_item(self.create_item("Moon Shoes")) + else: + for next_exit in r.autopelago_definition.exits: + r.connect(new_regions[next_exit], rule=rule) + + def get_filler_item_name(self): + assert "Nothing" in self.item_name_to_id + return "Nothing" + + def fill_slot_data(self): + return { + # version_stamp was more important in versions where you had to download an EXE file and + # make sure it's compatible with the version of the APWorld file that was used. all it's + # used for today is to give 0.10.x clients a string that they definitely don't expect so + # that they can throw a semi-graceful error message. + "version_stamp": "1.0.0", + "victory_location_name": self.victory_location, + "msg_changed_target": self.options.msg_changed_target.value, + "msg_enter_go_mode": self.options.msg_enter_go_mode.value, + "msg_enter_bk": self.options.msg_enter_bk.value, + "msg_remind_bk": self.options.msg_remind_bk.value, + "msg_exit_bk": self.options.msg_exit_bk.value, + "msg_completed_goal": self.options.msg_completed_goal.value, + "lactose_intolerant": bool(self.options.lactose_intolerant), + + # added in 1.0.0 so the client only needs to bake in items unlocking specific locations + "auras_by_item_id": self.enabled_auras_by_item_id, + "rat_counts_by_item_id": { + item_name_to_id[name]: rat_count for name, rat_count in item_name_to_rat_count.items() + }, + + # obsolete in 1.0.0 (auras_by_item_id does the same thing) but kept for 0.11.x client support: + "enabled_buffs": [EnabledBuffs.map[b] for b in self.options.enabled_buffs.value], + "enabled_traps": [EnabledTraps.map[t] for t in self.options.enabled_traps.value], + + # not working yet: + # "death_link": bool(self.options.death_link), + # "death_delay_seconds": self.options.death_delay_seconds - 0, + } + + def _sort_nonprogression_items_for_item_type(self, item_type: AutopelagoNonProgressionItemType): + """ + Returns a list of ALL non-progression items, sorted by how appropriate it would be to fill a + slot that expects an item of a given type. + """ + items_for_base_game = self._shuffled(items_by_game[GAME_NAME]) + included_games = set(self.multiworld.game.values()) - { GAME_NAME } + items_for_included_games = self._shuffled( + item for g, items in items_by_game.items() + for item in items + if g in included_games + ) + items_for_excluded_games = self._shuffled( + item for g, items in items_by_game.items() + for item in items + if g not in included_games + ) + + # all ideal items (true buffs when we're asked for buffs, true traps when we're asked for + # traps, true fillers when we're asked for fillers) come first, starting with the Easter egg + # items for games present in the multiworld, followed by base game items. Easter egg items + # for excluded games will be used only as a last resort. + # + # "true buff" = an item whose enabled auras are net positive + # "true trap" = an item whose enabled auras are net negative + # "true filler" = an item whose enabled aura effects cancel out AND whose disabled aura + # effects also cancel out. + # the remaining items are fillers, but not "true" + items: list[str] = [] + for items_list in (items_for_included_games, items_for_base_game): + items += ( + item for item in items_list + if self._distance_from_ideal(item_type, item) == 0 + ) + + # if item_type is "filler", then now is the time to add the non-"true" ones. + for items_list in (items_for_included_games, items_for_base_game): + items += ( + item for item in items_list + if 0 < self._distance_from_ideal(item_type, item) < ENABLED_AURA_SCORE_THRESHOLD + ) + + # fill in the values for excluded games as a last resort before we move onto things like + # yielding fillers when we're asked for traps / buffs + items += ( + item for item in items_for_excluded_games + if self._distance_from_ideal(item_type, item) == 0 + ) + items += ( + item for item in items_for_excluded_games + if 0 < self._distance_from_ideal(item_type, item) < ENABLED_AURA_SCORE_THRESHOLD + ) + + # when all buffs and all traps are enabled, we don't even come close to exhausting the lists + # of "ideal" items we built above. once things start getting disabled, we have to dip into + # filler items to complete that list. depending on how things will evolve in the future, we + # might even need to go beyond the fillers, so let's just extend this out to EVERYTHING that + # we can POSSIBLY draw from. + for items_list in (items_for_included_games, items_for_base_game, items_for_excluded_games): + items += sorted(( + item for item in items_list + if self._distance_from_ideal(item_type, item) >= ENABLED_AURA_SCORE_THRESHOLD + ), key=lambda val: self._distance_from_ideal(item_type, val)) + return items + + def _distance_from_ideal(self, item_type: AutopelagoNonProgressionItemType, item: str): + score = score_item_by_auras(item, self.enabled_auras) + match item_type: + case "useful_nonprogression": + return 0 if score > ENABLED_AURA_SCORE_THRESHOLD else abs(score) + (ENABLED_AURA_SCORE_THRESHOLD * 2) + case "trap": + return 0 if score < -ENABLED_AURA_SCORE_THRESHOLD else abs(score) + (ENABLED_AURA_SCORE_THRESHOLD * 2) + case _: + return abs(score) + + def _shuffled(self, items: Iterable[T]) -> list[T]: + to_shuffle = list(items) + self.multiworld.random.shuffle(to_shuffle) + return to_shuffle diff --git a/worlds/autopelago/archipelago.json b/worlds/autopelago/archipelago.json new file mode 100644 index 000000000000..f078006dd5b2 --- /dev/null +++ b/worlds/autopelago/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Autopelago", + "authors": ["airbreather", "skidznet"], + "minimum_ap_version": "0.6.1", + "world_version": "1.0.0" +} diff --git a/worlds/autopelago/definitions_types.py b/worlds/autopelago/definitions_types.py new file mode 100644 index 000000000000..0a9882808c80 --- /dev/null +++ b/worlds/autopelago/definitions_types.py @@ -0,0 +1,116 @@ +from typing import Literal, NotRequired, TypeAlias, TypedDict, Union + +Aura = Literal[ + "well_fed", + "lucky", + "energized", + "stylish", + "smart", + "confident", + "upset_tummy", + "unlucky", + "sluggish", + "distracted", + "startled", + "conspiratorial", +] + + +class AutopelagoItemDefinitionCls(TypedDict): + name: str | list[str] + rat_count: NotRequired[int] + flavor_text: NotRequired[str] + auras_granted: NotRequired[list[str]] + + +# AutopelagoItemDefinition can be in any of these formats: +# 1: [name, [aura1, aura2]] +# 2: [[name_with_lactose, lactose_intolerant_name], [aura1, aura2]] +# 3: +# - name: Yet Another Rat +# rat_count: 1 +# flavor_text: Some flavor text that shows up nowhere for now. +# auras_granted: [aura1, aura2] +AutopelagoItemDefinition = tuple[str | list[str], list[str]] | AutopelagoItemDefinitionCls +AutopelagoNonProgressionItemType = Literal["useful_nonprogression", "trap", "filler"] +AutopelagoGameRequirement: TypeAlias = Union[ + "AutopelagoAllRequirement", "AutopelagoAnyRequirement", "AutopelagoItemRequirement", + "AutopelagoRatCountRequirement", "AutopelagoAnyTwoRequirement"] + + +class AutopelagoAllRequirement(TypedDict): + all: list[AutopelagoGameRequirement] + + +class AutopelagoAnyRequirement(TypedDict): + any: list[AutopelagoGameRequirement] + + +class AutopelagoAnyTwoRequirement(TypedDict): + any_two: list[AutopelagoGameRequirement] + + +class AutopelagoItemRequirement(TypedDict): + item: str + + +class AutopelagoRatCountRequirement(TypedDict): + rat_count: int + + +class AutopelagoLandmarkRegionDefinition(TypedDict): + name: str + unrandomized_item: str + requires: AutopelagoGameRequirement + exits: list[str] | None + + +class AutopelagoItemKeyReferenceCls(TypedDict): + item: str + count: int + + +AutopelagoItemKeyReference = str | AutopelagoItemKeyReferenceCls + + +# "filler region" means that it's a region to fill out the locations, not that it's a region intended to contain filler +# items. naming things is hard >.< +class AutopelagoFillerRegionItemsDefinition(TypedDict): + # "key" as in "item key", as in "the key of the item in the 'items' section of this file", not as in "key item", + # even though all are, in fact, progression items. + key: list[AutopelagoItemKeyReference] + useful_nonprogression: int + filler: int + + +class AutopelagoFillerRegionDefinition(TypedDict): + name_template: str + unrandomized_items: AutopelagoFillerRegionItemsDefinition + ability_check_dc: int + exits: list[str] + + +class AutopelagoRegionDefinitions(TypedDict): + landmarks: dict[str, AutopelagoLandmarkRegionDefinition] + fillers: dict[str, AutopelagoFillerRegionDefinition] + + +class AutopelagoDefinitions(TypedDict): + items: dict[str, AutopelagoItemDefinitionCls] + regions: AutopelagoRegionDefinitions + + +class AutopelagoRegionDefinition: + key: str + exits: list[str] + locations: list[str] + requires: AutopelagoAllRequirement + landmark: bool + + def __init__(self, key: str, exits: list[str], locations: list[str], requires: AutopelagoGameRequirement, + landmark: bool): + self.key = key + self.exits = exits + self.locations = locations + self.requires = requires + self.landmark = landmark diff --git a/worlds/autopelago/docs/en_Autopelago.md b/worlds/autopelago/docs/en_Autopelago.md new file mode 100644 index 000000000000..b74f9a5d2165 --- /dev/null +++ b/worlds/autopelago/docs/en_Autopelago.md @@ -0,0 +1,58 @@ +# Autopelago + +## Where is the options page? + +The [player options page for this game](../player-options) contains all the options you need to configure and export a +config file. + +## What is this game? + +Autopelago is a game that plays itself, built specifically for Archipelago. It's meant to help practice playing the game +solo in a more realistic setting by adding other players in the world that need items from the multiworld to achieve +their goal — and vice versa. It also helps pad out a small multiworld with more items to make it more interesting or for +any other reason why you might want to fill more slots. + +The "player" is represented by a rat that moves around the map from location to location, trying to complete the task at +that location. When it succeeds, it sends out the check and moves onto the next one. + +Most locations are "fillers" to bulk out the map without any special rules by themselves, but then several locations are +"landmarks" that can only be completed if the rat has collected certain items. + +## What do items do? + +When the rat collects a progression item (other than a Pack Rat or the Rat Pack), then its icon in the panel on the left +will light up to show this. *Pack Rat and the Rat Pack will just increase your rat count by 1 or 5, respectively.* + +When the rat collects an item with buffs / traps on it (as enabled / disabled in your options), then the corresponding +meter or indicator will light up accordingly — though a few have upper limits on how high they can go. + +When the rat collects an item without any buffs / traps on it, then nothing happens. + +## What can I do in the game? + +The rat *primarily* runs around by itself, but there are some *limited* ways that you can influence what it does: + +1. In the game's chat, **any player** can type `@RatName go LOCATION`, where `RatName` is the rat's slot name (or alias, + if it's unique), and `LOCATION` is the name of a location on the map. *`@RatName stop LOCATION` will cancel such a + request, in case you changed your mind for whatever reason*. +2. In the game window itself, you can click on a specific location, and the rat will "hyper-focus" that location, + overriding everything else except the "Startled" debuff. +3. Also in the game window, you can click on an item in the left panel to request a hint, after a confirmation prompt. + +You can also click the rat icon to toggle on / off a dashed line showing its exact intended path to its current target, +and you can hover over any location (or the rat) to see some basic information about it in a tooltip. + +*For everything above that says "click", you can also tab over to it and press Enter to do the same, and their tooltips +also show up as you tab over to them.* + +## How does the rat decide where to go? + +It checks these rules in order and chooses the first one that applies: + +1. If it's startled, then it will run towards the beginning. +2. If it has a "hyper-focus" location, then it will run towards that. +3. If it has everything that it needs to complete the goal, then it will run towards the end. +4. If a "Smart" or "Conspiratorial" item has given the rat its own idea, then it will run towards that. +5. If any players have told it to go somewhere that it can reach, then it will run towards the earliest requested one. +6. If it can reach any unchecked location, then it will run towards the nearest one. +7. Otherwise, it will stay where it is. diff --git a/worlds/autopelago/docs/setup_en.md b/worlds/autopelago/docs/setup_en.md new file mode 100644 index 000000000000..c1cc4792559d --- /dev/null +++ b/worlds/autopelago/docs/setup_en.md @@ -0,0 +1,27 @@ +# Autopelago Setup Guide + +## Required Software + +- A browser with JavaScript enabled (you are probably using one right now!). + +## Playing the game +Open the Autopelago website. There are two options: +- The easiest option is the [Autopelago Website](https://autopelago.app/). If the website is unavailable, use the next + option. +- Download the latest release from [Autopelago Release](https://github.com/airbreather/Autopelago/releases/latest) and + unzip the `dist.zip`. You will need to host the files using a web server instead of just opening `index.html` in your + browser, because of some stuff that browsers do to prevent malicious websites from getting your secrets. Basically any + web server should work (it's just a static website): + - If you have Python installed for other reasons, you can run `python -m http.server 8000` in the directory containing + the files and browse to [http://localhost:8000](http://localhost:8000). + - If Docker is your jam, you can run `docker run -it --rm -p 8000:80 -v $(pwd):/usr/share/nginx/html nginx` from the + directory containing the files and then browse to [http://localhost:8000](http://localhost:8000). + - If you're lost at this point and **really** can't use the main website for some reason, you could try + [Static Web Server](https://static-web-server.net/getting-started). That link goes to their "Getting Started" page. + +Enter the standard login information, change any settings you want (normally, the defaults are fine), and you're off! + +The website has a built-in client, where you can chat and send commands. + +For more information on generating Archipelago games and connecting to servers, please see the +[Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en). diff --git a/worlds/autopelago/items.py b/worlds/autopelago/items.py new file mode 100644 index 000000000000..6548cfd93288 --- /dev/null +++ b/worlds/autopelago/items.py @@ -0,0 +1,175 @@ +import importlib.resources +import pathlib +import sys + +from BaseClasses import ItemClassification +from Utils import parse_yaml + +from .definitions_types import ( + Aura, + AutopelagoItemDefinition, + AutopelagoNonProgressionItemType, +) +from .util import defs, gen_ids + +names_with_lactose = set() +lactose_intolerant_names = set() +nonprogression_item_types: list[AutopelagoNonProgressionItemType] = \ + ["useful_nonprogression", "trap", "filler"] + + +def _to_lactose_name(name_or_list: str | list[str]) -> str: + if isinstance(name_or_list, str): + return name_or_list + return name_or_list[0] + + +def _lactose_name_of(item: AutopelagoItemDefinition): + return \ + _to_lactose_name(item[0]) if isinstance(item, list) else \ + _to_lactose_name(item["name"]) + + +def _to_lactose_intolerant_name(name_or_list: str | list[str]) -> str: + if isinstance(name_or_list, str): + return name_or_list + return name_or_list[1] + + +def _lactose_intolerant_name_of(item: AutopelagoItemDefinition): + return \ + _to_lactose_intolerant_name(item[0]) if isinstance(item, list) else \ + _to_lactose_intolerant_name(item["name"]) + + +def _names_of(item: AutopelagoItemDefinition): + lactose_name = _lactose_name_of(item) + lactose_intolerant_name = _lactose_intolerant_name_of(item) + if lactose_name == lactose_intolerant_name: + return [lactose_name] + names_with_lactose.add(lactose_name) + lactose_intolerant_names.add(lactose_intolerant_name) + return [lactose_name, lactose_intolerant_name] + + +def _rat_count_of(item: AutopelagoItemDefinition): + return item["rat_count"] if isinstance(item, dict) and "rat_count" in item else None + + +def _auras_of(item: AutopelagoItemDefinition) -> list[Aura]: + return \ + item[1] if isinstance(item, list) else \ + item["auras_granted"] if "auras_granted" in item else \ + [] + + +item_name_to_auras: dict[str, list[Aura]] = {} +progression_item_names: set[str] = set() +item_name_to_rat_count: dict[str, int] = {} +items_by_game: dict[str, list[str]] = {} +item_key_to_name: dict[str, str] = {} +_item_id_gen = gen_ids() +item_name_to_id: dict[str, int] = {} + +# since some buffs / traps can be disabled for a particular multiworld, it would be not-quite-right to put each item +# into a fixed "buff" / "trap" / "filler" category like earlier versions once did. instead, assign a score to each aura +# based on what it does, and determine that category based on the combination of the item's auras *that are enabled*. +for k, v in defs["items"].items(): + for _name in _names_of(v): + item_name_to_auras[_name] = _auras_of(v) + item_name_to_id[_name] = next(_item_id_gen) + progression_item_names.add(_name) + rat_count = _rat_count_of(v) + if rat_count and rat_count > 0: + item_name_to_rat_count[_name] = rat_count + item_key_to_name[k] = _name + + +def _append_items(item_definitions: list[AutopelagoItemDefinition]): + res: list[str] = [] + for item_definition in item_definitions: + for _name in _names_of(item_definition): + res.append(_name) + item_name_to_auras[_name] = _auras_of(item_definition) + item_name_to_id[_name] = next(_item_id_gen) + rat_count = _rat_count_of(item_definition) + if rat_count and rat_count > 0: + item_name_to_rat_count[_name] = rat_count + return res + + +# importlib.resources.files changed in Python 3.12 to work a bit more sensibly for our situation. it's not really worth +# finding a way to build a string that will work here in Python 3.11 (which is essentially one foot out the door at the +# time of writing). just do what makes 3.11 work, but build it "correctly" for the future (airbreather 2025-12-09). +anchor = \ + __name__ if sys.version_info >= (3, 12) else \ + "worlds.autopelago" +for package_file_or_dir in importlib.resources.files(anchor).iterdir(): + if package_file_or_dir.name != "items_by_game": + continue + for f in package_file_or_dir.iterdir(): + game_name = pathlib.Path(f.name).with_suffix("").stem + items_by_game[game_name] = _append_items(parse_yaml(f.open("r"))) + +item_name_groups: dict[str, set[str]] = { + "Sewer Progression": set(), + "Cool World Progression": set(), + "Space Progression": set(), + "Rats": {k for k, v in item_name_to_rat_count.items() if v}, + "Special Rats": {k for k, v in item_name_to_rat_count.items() if v and k != item_key_to_name["pack_rat"]}, + "Progression Items": set(), +} + +for item_name in item_name_to_id: + if item_name in progression_item_names: + item_name_groups["Progression Items"].add(item_name) + + auras = item_name_to_auras[item_name] + for aura in auras: + nice_name = aura.replace("_", " ").title() + + item_group_all = f"Gives {nice_name}" + item_group_only = f"Gives Only {nice_name}" + item_name_groups.setdefault(item_group_all, set()).add(item_name) + if len(set(auras)) == 1: + item_name_groups.setdefault(item_group_only, set()).add(item_name) + +_aura_classification_points: dict[Aura, int] = { + "well_fed": 5, + "lucky": 3, + "energized": 5, + "stylish": 1, + "smart": 1, + "confident": 3, + "upset_tummy": -5, + "unlucky": -1, + "sluggish": -5, + "distracted": -3, + "startled": -6, + "conspiratorial": -1, +} +ENABLED_AURA_SCORE_MULTIPLIER = 1_000_000 +ENABLED_AURA_SCORE_THRESHOLD = 100_000 + +def score_item_by_auras(item: str, enabled_auras: set[Aura]) -> int: + score = 0 + for aura in item_name_to_auras[item]: + # disabled auras contribute a tiny bit to the score. they won't influence the score enough + # to overpower the effects of even the weakest enabled aura, but by keeping their relative + # pushes/pulls on the overall score intact, we ensure that, e.g., when asking for a "filler" + # item, we'll usually tend to pick items that would be fillers with all buffs/traps enabled + # before items that are normally buffs/traps but just happen to have everything disabled. + # I'm sure there will be exceptions, but it's not SUPER important to do a FANTASTIC job at + # this, so I'm not spending more time on it right now. + tmp = _aura_classification_points[aura] + if aura in enabled_auras: + tmp *= ENABLED_AURA_SCORE_MULTIPLIER + score += tmp + return score + + +def classify_item_by_score(score: int) -> ItemClassification: + return \ + ItemClassification.useful if score > ENABLED_AURA_SCORE_THRESHOLD else \ + ItemClassification.trap if score < -ENABLED_AURA_SCORE_THRESHOLD else \ + ItemClassification.filler diff --git a/worlds/autopelago/items_by_game/A Link to the Past.yml b/worlds/autopelago/items_by_game/A Link to the Past.yml new file mode 100644 index 000000000000..31c4d3db672a --- /dev/null +++ b/worlds/autopelago/items_by_game/A Link to the Past.yml @@ -0,0 +1,12 @@ +- ['Pendant of Dim Sum', [well_fed]] +- ['Pendant of Flour', [well_fed]] +- ['Pendant of Porridge', [well_fed]] +- ['Titan''s Cooking Mitts', [stylish]] +- ['Validation Rupee', [confident]] +- ['Cabbage-Colored Boulder', [startled]] +- ['Third Red Crystal', [conspiratorial]] +- ['The Spoon Pearl', []] +- ['Agahnim''s Shiny Ball', []] +- ['The Green Mail', []] +- ['Half Bottle of Pink Hair Dye', []] +- ['REALLY Small Key to Hyrule Castle', []] diff --git a/worlds/autopelago/items_by_game/Autopelago.yml b/worlds/autopelago/items_by_game/Autopelago.yml new file mode 100644 index 000000000000..a408d4dcf3e3 --- /dev/null +++ b/worlds/autopelago/items_by_game/Autopelago.yml @@ -0,0 +1,304 @@ +- ['Refreshing Glass of Lemonade', [well_fed]] +- ['Bag of Powdered Sugar', [well_fed, energized, energized, energized]] +- ['Organic Apple Core', [well_fed]] +- ['An Entire Roast Chicken', [well_fed]] +- ['Plate of Spaghetti', [well_fed]] +- ['Freshly Baked Bread', [well_fed]] +- ['Taco Salad that is Only Tacos', [well_fed]] +- [['Subscription to the Cheese of the Month Club', 'Subscription to Spider of the Month Club'], [well_fed, well_fed, well_fed]] +- ['Soup with a Hair in it', [well_fed, distracted]] +- ['Can of Spam', [well_fed]] +- ['Fruit Roll-Up', [well_fed]] +- ['Fresh-baked Apple Pie', [well_fed]] +- ['Dark Blue Ghost', [well_fed, confident]] +- ['Mundane Pickle', [well_fed]] +- ['Finest Potion', [energized, lucky]] +- ['Extra Crunchy Peanut Butter', [well_fed]] +- [['Cheesenado', 'Spidernado'], [well_fed, well_fed, well_fed, startled]] +- ['Bowl of Cereal', [well_fed]] +- ['Cake', [well_fed]] +- ['Dungeon Bread', [well_fed]] +- ['Gallon of Diet Soda', [well_fed, energized]] +- ['Apple wearing Jeans', [well_fed, stylish]] +- [['Macaroni and Cheese', 'Macaroni and Spiders'], [well_fed]] +- [['Pint of Ice Cream', 'Pint of Iced Spiders'], [well_fed]] +- ['Protein Shake', [well_fed, confident]] +- [['Tray of Lasagna', 'Tray of Spider-filled Lasagna'], [well_fed]] +- ['Best Burgers in Town', [well_fed, well_fed]] +- ['Pack of Pickled Peppers', [well_fed]] +- [['Sword made out of Chocolate', 'Sword made out of Spider-Based Chocolate'], [well_fed, confident]] +- ['Lovely Bunch of Coconuts', [well_fed]] +- ['Faux Dalmatian-Skin Coat', [stylish]] +- ['Bedazzled Cowboy Boots', [stylish]] +- ['Fancy Rat-sized Tophat', [stylish]] +- ['Tin-Foil Hat', [stylish, stylish, conspiratorial]] +- ['Backwards Cap', [stylish, confident]] +- ['Fake Moustache', [stylish]] +- ['Pair of 3D Glasses', [stylish, smart]] +- ['Comfy Shorts', [stylish]] +- ['Jetpack', [stylish, energized]] +- ['Plague Doctor''s Mask', [stylish, confident]] +- ['5th Ace', [lucky]] +- ['Get Out of Jail Free Card', [confident]] +- ['Winning Lottery Ticket', [lucky, lucky]] +- ['Holographic Draw Four Card', [lucky, lucky]] +- ['Letter from a Secret Admirer', [lucky]] +- ['Line-shaped Tetris Block', [lucky]] +- ['Inspiring Montage', [lucky]] +- ['Surprisingly Tiny Knife', [confident]] +- ['Packet of Ketchup', [well_fed]] +- ['Blue-eyes White Alligator Trading Card', [lucky]] +- ['Michelin Star', [lucky]] +- ['Damp Pineapple', [well_fed]] +- ['USB Containing Government Secrets', [smart]] +- ['Smelly Vintage T-Shirt', [stylish]] +- ['McRib', [well_fed]] +- ['Phylactery', [lucky, smart]] +- ['Cool Ninja Weapons', [confident]] +- ['Bowl of Computer Chips', [smart]] +- ['Golden Ticket', [lucky]] +- ['Enchanted Guitar Pick', [lucky, confident]] +- ['Hall Pass', [energized]] +- ['Limited Edition Vintage Superhero Lunch Box Complete with Thermos', [stylish]] +- ['Sweet Roadtrip Mixtape', [energized]] +- ['Bright Idea', [smart]] +- ['Pluto', [smart]] +- ['Autographed Copy of the Bible', [lucky]] +- ['Overdue Library Book', [smart]] +- ['Oxford Comma', [smart]] +- ['4th-Dimensional Hypercube', [smart]] +- ['RGB Lighting', [smart]] +- ['Rave Reviews', [confident]] +- ['Chaotic Emerald', [energized]] +- ['Brand New Car', [energized]] +- ['Coffee Mug Full of Pencils', [smart]] +- ['Squeaky Mallet', [confident]] +- ['Starting Equipment', [confident, stylish]] +- ['Someone Else''s Shoes', [stylish]] +- ['Magic Bath Mat', [energized]] +- ['Frog-shaped Chair', [stylish]] +- ['5 Elemental-themed Rings', [confident]] +- ['Wardrobe of Alternate Dimensions', [energized]] +- ['Sassy Robot Companion', [smart]] +- ['Bomb-proof Refrigerator', [confident]] +- ['Legally Distinct Red Laser Sword', [confident]] +- ['Weapons-grade Folding Chair', [confident]] +- ['Bribe', [confident]] +- ['Spilled Bag of Rice', [well_fed, distracted]] +- ['Pinball Wizard''s Spellbook', [smart, confident, distracted]] +- ['Dino DNA', [smart]] +- ['Friend Ship', [confident]] +- ['5D Printer', [smart]] +- ['Lightbulb in a Briefcase', [lucky]] +- ['Breakfast Machine', [well_fed, well_fed, distracted]] +- ['Toilet-scented Perfume', [stylish]] +- ['Bouquet of Flour', [well_fed]] +- ['Pumpkin Pie Chart', [well_fed]] +- ['Acoustic Guitar', [confident]] +- ['Pear', [well_fed]] +- ['Pair of Pears', [well_fed, well_fed]] +- ['Pair of Pair of Pears', [well_fed, well_fed, well_fed]] +- ['Dozen Raw Eggs', [well_fed]] +- ['Lovely Meatloaf', [well_fed]] +- ['Mug of Burnt Bean Water', [energized]] +- ['Cup of Fancy Leaf Water', [energized]] +- [['Microwave Full of Brownies', 'Microwave Full of Spider-Based Brownies'], [well_fed]] +- ['Dreihander', [confident]] +- ['A Reasonably Priced Textbook', [smart]] +- ['Sandwich that is Literally One Mile Tall', [well_fed, well_fed, well_fed, well_fed, well_fed, well_fed, distracted]] +- ['Lockheed SR-71 Blackbird', [energized, energized, smart]] +- ['Half of a Worm', [upset_tummy]] +- ['Extra-Well-Done Steak', [upset_tummy]] +- ['Your Friendly Neighborhood Tapeworm', [upset_tummy, upset_tummy, upset_tummy]] +- ['Honey Roasted Packing Peanuts', [upset_tummy]] +- ['Rat Poison', [upset_tummy, upset_tummy, upset_tummy, unlucky, startled, startled, startled, sluggish]] +- ['Poisonous Mushroom', [upset_tummy, upset_tummy, unlucky]] +- ['Too Much Eggnog', [upset_tummy]] +- ['Peanut Boulder and Jelly Sandwich', [upset_tummy, sluggish]] +- ['Forgotten Moldy Fruit Basket', [upset_tummy]] +- ['Pie with a Bird Hiding Inside', [upset_tummy, startled]] +- ['Expired Health Potion', [upset_tummy, sluggish]] +- ['Gas Station Sushi', [upset_tummy]] +- ['Carbon Monoxide', [sluggish, startled]] +- ['Circus Flea', [distracted]] +- ['Game Bug', [conspiratorial]] +- ['Too Many Crabs', [startled]] +- ['Ticket for the Off-Broadway Musical Rats', [distracted]] +- ['Polybius Arcade Cabinet', [conspiratorial, distracted]] +- ['An Illusion', [distracted]] +- ['Cartoonishly Large Bomb', [startled, startled, startled]] +- ['Hungry Hippopotamus', [startled]] +- ['Malfunctioning Boomerang', [distracted, unlucky]] +- ['Song that Never Ends', [distracted]] +- ['Bubble Wrap', [distracted]] +- ['Box of Fireworks', [startled, startled]] +- ['Cabin Fever', [sluggish]] +- ['Self-destruct Button', [startled, startled]] +- ['Train with a Scary Face', [startled]] +- ['Greater White Shark', [startled]] +- ['Rude Internet Comment', [unlucky, distracted]] +- ['Extra Premium Currency best value', [conspiratorial, distracted]] +- ['Distracting Squirrel', [distracted]] +- ['Itchy Iron Wool Sweater', [stylish, distracted, sluggish]] +- ['Mail-in Rebate for 11 cents', [distracted]] +- ['Spicy Magazine', [distracted, upset_tummy]] +- ['Wooden Splinters', [sluggish]] +- ['Box Set of a Canceled TV Show', [distracted]] +- ['Burning Phone', [startled]] +- ['One-way Ticket to Ohio', [startled, startled, startled, distracted]] +- ['Deceased Pet Rock', [sluggish, unlucky]] +- ['Cursed Slab', [unlucky, unlucky, unlucky]] +- ['Microplastic Pile', [upset_tummy]] +- ['Copy of E.T. the Extra-Terrestrial for Atari 2600', [distracted]] +- ['3 Dollar Bill', [conspiratorial]] +- ['HD Photo of Bigfoot', [conspiratorial]] +- ['Yesterday''s Horoscope', [conspiratorial]] +- ['Actual Lava Lamp', [startled]] +- ['Proof that Aliens Exist', [conspiratorial]] +- ['Bottled Toilet Water', [upset_tummy]] +- ['Beanie Baby in a Pot of Chili', [upset_tummy]] +- ['5G Wireless Technology', [conspiratorial]] +- ['Real Dog Poop', [upset_tummy]] +- ['Aggressive Post-it Notes', [startled, distracted]] +- ['Little White Lie', [conspiratorial]] +- ['Annoying Fairy', [distracted]] +- ['Scary Baby Doll', [startled]] +- ['Probably Decommissioned Warhead', [startled]] +- ['Waldo''s Home Address', [conspiratorial]] +- ['Stapler in Jell-O', [sluggish]] +- ['Radioactive Green Ooze', [startled, sluggish, upset_tummy]] +- [['Bottle of Spilled Milk', 'Bottle of Spilled Spiders'], [unlucky]] +- ['Just Some Sludge', [sluggish]] +- ['Sticky Video Game Controller', [sluggish]] +- ['Lice-filled Wig', [distracted]] +- ['Greasy Paper Bag', [upset_tummy]] +- ['Squeaky Vent Flap', [startled]] +- ['Monkey''s Paw', [lucky, conspiratorial, startled]] +- ['Animatronic Mouse', [startled, distracted]] +- ['Soiled Rug', [sluggish]] +- ['Infernal Puzzle Box', [unlucky, startled, distracted, smart]] +- ['Human Declaration of Independence', [conspiratorial]] +- ['Alligator in a Vest', [startled]] +- ['Moist Owlet', [startled]] +- ['Terrible Pun', [distracted]] +- ['Weather Balloon', [conspiratorial]] +- ['Empty Snail Shell', []] +- ['Loose Screw', []] +- ['Set of Car Keys', []] +- ['Not Very Sharp Tool', []] +- ['Busted Flute', []] +- ['Beartrap', []] +- ['Nonmagic 8-ball', []] +- ['Carpentry for Ants', []] +- ['Partially Used Blockbuster Gift Card', []] +- ['Year-supply of Calendars', []] +- ['Human-sized Skateboard', []] +- ['Loose Staples', []] +- ['Old Boot', []] +- ['Clump of Hair', []] +- ['Collectable Plate', []] +- ['Roll of Toilet Paper', []] +- ['Misplaced Pixel', []] +- ['Lost Puzzle Piece', []] +- ['Cubic Piece of Dirt', []] +- ['Plank of Wood', []] +- ['Scented Candle', []] +- ['Broken Pottery', []] +- ['World''s Smallest Violin', []] +- ['Crumpled Paper Airplane', []] +- ['Half-eaten Pencil', []] +- ['Headlight Fluid', []] +- ['Help I''m Trapped in This Game and this is the only Way i Know how to Contact you Please get Me Out', []] +- ['Broken Fishing Rod', []] +- ['Toy Boat Toy Boat Toy Boat', []] +- ['Twenty Matches', []] +- ['Cracked Monopoly Board', []] +- ['AAAAAA battery', []] +- ['Cat-shaped Wall Clock', []] +- ['Printer Driver Disc', []] +- ['Off-brand Soda Can', []] +- ['Set of Three Seashells', []] +- ['Chewed Bar of Soap', []] +- ['Generic Green Slime', []] +- ['Handful of Loose Marbles', []] +- ['Discarded Video Game Cartridge', []] +- ['Lit Candle', []] +- ['The Hit Board Game Mouse Trap', []] +- ['Rusty Pocketwatch', []] +- ['Right Sock', []] +- ['Shrimp in a Bottle', []] +- ['Not a Doll, but an Action Figure', []] +- ['Small chain of Islands', []] +- ['School Photo', []] +- ['Sack with a Dollar Sign Painted on it', []] +- ['Statue of David''s Dog', []] +- ['Radio Controlled Car', []] +- ['Dihydrogen Monoxide', []] +- ['Rubber Duck', []] +- ['98 Red Balloons', []] +- ['Red Balloon', []] +- ['My Little Capybara', []] +- ['The Titanic', []] +- ['Canned Soup', []] +- ['Left Sock', []] +- ['Nintendo 65', []] +- ['Fake Dog Poop', []] +- ['Elephant in the Room', []] +- ['Helical Fossil', []] +- ['Theodore Roosevelt Plushie', []] +- ['Bag of Wires You Might Need One of These Days', []] +- ['Naughty Coal', []] +- ['Mushroom Princess', []] +- ['Novelty Keychain', []] +- ['Spare Axle', []] +- ['Elevator Music', []] +- ['Radical Rock', []] +- ['Sizzlin Scissors', []] +- ['Just Paper', []] +- ['Trash Can Lid', []] +- ['Crystal Skull', []] +- ['Hairless Yak', []] +- ['Pocket', []] +- ['Player 2', []] +- ['Evil Plans', []] +- ['Cardboard Box', []] +- ['Lawn Flamingo', []] +- ['Withered Bonsai Tree', []] +- ['Defeated Punching Bag', []] +- ['3 Easy Payments of 19.95', []] +- ['Bag of Normal Beans', []] +- ['The Krebs Cycle', []] +- ['Ring of Visibility', []] +- ['Handful of Glitter', []] +- ['Signed Air Guitar', []] +- ['Neat Rock', []] +- ['Dijkstra''s Algorithm', []] +- ['Fire Distinguisher', []] +- ['Censor Bar', []] +- ['Extended Warranty', []] +- ['Whatever a Credenza Is', []] +- ['Corpse-pokin'' Stick', []] +- ['CD containing ''Sounds of the Sewer''', []] +- ['Rule stating that Rats Cannot Play Basketball', []] +- ['Hilarious Mushroom', [energized, upset_tummy]] +- ['Insultingly Bad Portrait', []] +- ['Rusted Oil Can', []] +- ['Sleepy Fish', []] +- ['Slobber-covered Ball', []] +- ['Two-bedroom Apartment', []] +- ['Stack of Cue Cards', []] +- ['One Photon', []] +- ['Freshly Squeezed Snake Oil', []] +- ['Bovine Credit Card', []] +- ['Nothing', []] +- ['Busted Piggy Bank', []] +- ['English Subtitles', []] +- ['I can''t believe that this isn''t a key item', []] +- ['Updog', []] +- ['Just a regular ol'' chest, trust me', []] +- ['Blown Fuse', []] +- [['Cheese Strats', 'Spider Strats'], []] +- ['Cosmic Ray Bit Flip', []] +- name: ['A Cookie', 'A Spider Chip Cookie'] + auras_granted: [well_fed] + flavor_text: 'We all know what happens when you give a MOUSE a cookie...' diff --git a/worlds/autopelago/items_by_game/Celeste 64.yml b/worlds/autopelago/items_by_game/Celeste 64.yml new file mode 100644 index 000000000000..2a36ec054753 --- /dev/null +++ b/worlds/autopelago/items_by_game/Celeste 64.yml @@ -0,0 +1,5 @@ +- ['One Raspberry', [well_fed]] +- ['Theo''s Winter Coat', [stylish]] +- ['Permafrosted Strawberry', [upset_tummy]] +- ['Malfunctioning Cassette Tape', []] +- ['Photograph of Celeste Mountain', []] diff --git a/worlds/autopelago/items_by_game/DLCQuest.yml b/worlds/autopelago/items_by_game/DLCQuest.yml new file mode 100644 index 000000000000..5cf53b795e9a --- /dev/null +++ b/worlds/autopelago/items_by_game/DLCQuest.yml @@ -0,0 +1,2 @@ +- ['Armor for Your Rat Pack', [stylish]] +- ['Autopelago: Coin Bundle', []] diff --git a/worlds/autopelago/items_by_game/DOOM 1993.yml b/worlds/autopelago/items_by_game/DOOM 1993.yml new file mode 100644 index 000000000000..3ed86eaf5488 --- /dev/null +++ b/worlds/autopelago/items_by_game/DOOM 1993.yml @@ -0,0 +1,2 @@ +- ['Misplaced Keycard', [lucky]] +- ['Monster Closet', [startled]] diff --git a/worlds/autopelago/items_by_game/Dark Souls III.yml b/worlds/autopelago/items_by_game/Dark Souls III.yml new file mode 100644 index 000000000000..f59e75835983 --- /dev/null +++ b/worlds/autopelago/items_by_game/Dark Souls III.yml @@ -0,0 +1,5 @@ +- ['Severely Scorched Sword', [confident]] +- ['Siegward''s Jar of Pickled Onions', [well_fed]] +- ['Asbestos Flask', [upset_tummy]] +- ['Soul of a Cinder Block', []] +- ['Treasure Chest with Several Stab Wounds', []] diff --git a/worlds/autopelago/items_by_game/Factorio.yml b/worlds/autopelago/items_by_game/Factorio.yml new file mode 100644 index 000000000000..1a48ca523583 --- /dev/null +++ b/worlds/autopelago/items_by_game/Factorio.yml @@ -0,0 +1,5 @@ +- [['Cheese Science Pack', 'Spider Science Pack'], [well_fed]] +- ['Eau de Pollution', [sluggish]] +- ['Bottleneck', []] +- ['Chain of Daisies', []] +- ['Cuddly Little Biter', []] diff --git a/worlds/autopelago/items_by_game/Kingdom Hearts 2.yml b/worlds/autopelago/items_by_game/Kingdom Hearts 2.yml new file mode 100644 index 000000000000..4caf6cf41390 --- /dev/null +++ b/worlds/autopelago/items_by_game/Kingdom Hearts 2.yml @@ -0,0 +1,4 @@ +- ['Lucky Rat Emblem', [lucky]] +- [['Proof of Cheese', 'Proof of Spiders'], []] +- name: 'One-Winged Rat' + rat_count: 1 diff --git a/worlds/autopelago/items_by_game/Links Awakening DX.yml b/worlds/autopelago/items_by_game/Links Awakening DX.yml new file mode 100644 index 000000000000..20033abeab29 --- /dev/null +++ b/worlds/autopelago/items_by_game/Links Awakening DX.yml @@ -0,0 +1,7 @@ +- ['Salad of the Wind Fish', [well_fed]] +- ['Frog''s Song of Soup', [well_fed]] +- ['Manbo''s Mango', [well_fed]] +- ['Poorly Tuned Wind Marimba', [unlucky]] +- ['Malfunctioning Trendy Game Claw', [distracted]] +- ['Pocket Full of Koho Lint', []] +- ['Tortoise-Shaped Rock', []] diff --git a/worlds/autopelago/items_by_game/Minecraft.yml b/worlds/autopelago/items_by_game/Minecraft.yml new file mode 100644 index 000000000000..d231437cabda --- /dev/null +++ b/worlds/autopelago/items_by_game/Minecraft.yml @@ -0,0 +1,15 @@ +- ['Pie of Ender', [well_fed]] +- [['Structure Compass (Cheese Wheel)', 'Structure Compass (Spider Wheel)'], [smart]] +- ['Enchanted Netherite Fishing Rod', [confident]] +- ['Carbonated Potion of Swiftness II', [energized]] +- ['Villager''s Birthday Party Invitation', [distracted]] +- ['Redstone Tutorial', [distracted]] +- ['An Apple Covered in Actual Gold', [upset_tummy]] +- ['Potion of Bad Omen V', [unlucky]] +- ['Half-Cut Tree', [distracted]] +- ['Regressive Pickaxe', [sluggish]] +- ['Gnawing III Book', []] +- [['Progressive Cheesecrafting', 'Progressive Spidercrafting'], []] +- ['Netherite Bed', []] +- ['Shattered Ender Pearl', []] +- [['Spawn Egg (Cheese Golem)', 'Spawn Egg (Spider Golem)'], []] diff --git a/worlds/autopelago/items_by_game/Ocarina of Time.yml b/worlds/autopelago/items_by_game/Ocarina of Time.yml new file mode 100644 index 000000000000..51c49b7ea192 --- /dev/null +++ b/worlds/autopelago/items_by_game/Ocarina of Time.yml @@ -0,0 +1,11 @@ +- ['Silver Skulltula Token', [lucky]] +- ['Pickled Cucco Feet', [well_fed]] +- ['Song of Thyme', [well_fed]] +- ['Ganondorf''s Tennis Racket', [confident]] +- ['Goron Rock Candy', [well_fed]] +- ['Overly-Talkative Fairy in a Bottle', [distracted]] +- [['Spoiled Lon Lon Milk', 'Spoiled Bottle of Lon Lon Skulltulas'], [upset_tummy]] +- ['Unhappy Mask Salesman', [startled]] +- ['Broken Boss Key', []] +- ['Jabu-Jabu''s Missing Kidney', []] +- ['Lite Brite Arrow', []] diff --git a/worlds/autopelago/items_by_game/OpenRCT2.yml b/worlds/autopelago/items_by_game/OpenRCT2.yml new file mode 100644 index 000000000000..b07b3b367676 --- /dev/null +++ b/worlds/autopelago/items_by_game/OpenRCT2.yml @@ -0,0 +1,6 @@ +- [['Cheese Stall', 'Spider Stall'], [well_fed]] +- ['Mouse Convention', [startled, distracted]] +- ['Wooden Wild Rat Coaster', [distracted]] +- [['Allow Brie Removal', 'Allow Spiderweb Removal'], []] +- name: '1 Rat Guest' + rat_count: 1 diff --git a/worlds/autopelago/items_by_game/Pokemon Emerald.yml b/worlds/autopelago/items_by_game/Pokemon Emerald.yml new file mode 100644 index 000000000000..455a19064a72 --- /dev/null +++ b/worlds/autopelago/items_by_game/Pokemon Emerald.yml @@ -0,0 +1,6 @@ +- ['Aqua Branded Water Bottle', [well_fed]] +- ['Magma Branded Hot Sauce', [well_fed]] +- ['Rabid Zigzagoon', [startled]] +- ['Kevin Scope', []] +- ['Ash-Filled Backpack', []] +- ['Peeko''s Boating License', []] diff --git a/worlds/autopelago/items_by_game/Pokemon Red and Blue.yml b/worlds/autopelago/items_by_game/Pokemon Red and Blue.yml new file mode 100644 index 000000000000..442c11c76184 --- /dev/null +++ b/worlds/autopelago/items_by_game/Pokemon Red and Blue.yml @@ -0,0 +1,12 @@ +- ['Very Common Candy', [well_fed]] +- ['Icy Blue Feather', [lucky]] +- ['Burning Orange Feather', [lucky]] +- ['Charged Yellow Feather', [lucky]] +- ['Vermilion City Truck Keys', [confident]] +- ['Item-Hider', [distracted]] +- ['Team Rocket Membership Card', [conspiratorial]] +- ['Oak''s Other Parcel', []] +- ['Silph Scoop', []] +- ['Mustard Ball', []] +- ['HM02.5 Walk', []] +- ['Rat Fossil', []] diff --git a/worlds/autopelago/items_by_game/Pseudoregalia.yml b/worlds/autopelago/items_by_game/Pseudoregalia.yml new file mode 100644 index 000000000000..0620ec38c764 --- /dev/null +++ b/worlds/autopelago/items_by_game/Pseudoregalia.yml @@ -0,0 +1 @@ +- ['20-Bean Casserole', [well_fed, well_fed]] diff --git a/worlds/autopelago/items_by_game/Slay the Spire.yml b/worlds/autopelago/items_by_game/Slay the Spire.yml new file mode 100644 index 000000000000..105c5dbd8b89 --- /dev/null +++ b/worlds/autopelago/items_by_game/Slay the Spire.yml @@ -0,0 +1,2 @@ +- ['Unmatched Power', [confident]] +- ['Exhausting Card', [sluggish]] diff --git a/worlds/autopelago/items_by_game/Sonic Adventure DX.yml b/worlds/autopelago/items_by_game/Sonic Adventure DX.yml new file mode 100644 index 000000000000..a8c135bdcb16 --- /dev/null +++ b/worlds/autopelago/items_by_game/Sonic Adventure DX.yml @@ -0,0 +1,6 @@ +- ['Oh No!', [startled]] +- ['Oh No?', [unlucky]] +- ['Oh No...', [sluggish]] +- ['Frog with a Tail', []] +- name: 'Big the Rat' + rat_count: 1 diff --git a/worlds/autopelago/items_by_game/Stardew Valley.yml b/worlds/autopelago/items_by_game/Stardew Valley.yml new file mode 100644 index 000000000000..e567dce4d4f4 --- /dev/null +++ b/worlds/autopelago/items_by_game/Stardew Valley.yml @@ -0,0 +1,8 @@ +- ['Prismatic Chard', [well_fed]] +- [['Cheese Seeds', 'Spider Seeds'], [well_fed]] +- ['JojaMart Sale Coupon', [lucky]] +- ['Pickled Dust Sprite', [well_fed]] +- ['Rotten Parsnip', [upset_tummy]] +- ['Rotten Walnut', [upset_tummy]] +- ['Abigail''s Birth Certificate', [conspiratorial]] +- ['Mayor''s Tax Returns', []] diff --git a/worlds/autopelago/items_by_game/Super Mario 64.yml b/worlds/autopelago/items_by_game/Super Mario 64.yml new file mode 100644 index 000000000000..3d25a0e36c1e --- /dev/null +++ b/worlds/autopelago/items_by_game/Super Mario 64.yml @@ -0,0 +1,9 @@ +- ['Lucky MIPS Foot', [lucky]] +- ['Metal Moustache', [stylish]] +- ['Rat Cap', [stylish]] +- ['Red-Hatted Monkey', [distracted]] +- ['Green Demon', [startled]] +- ['Ninth Red Coin', []] +- ['Key to the Castle''s Bathroom', []] +- ['0.5x A Presses', []] +- ['Cannon Unlock PPK (Princess Peach''s Kitchen)', []] diff --git a/worlds/autopelago/items_by_game/Super Mario World.yml b/worlds/autopelago/items_by_game/Super Mario World.yml new file mode 100644 index 000000000000..4968736ac03a --- /dev/null +++ b/worlds/autopelago/items_by_game/Super Mario World.yml @@ -0,0 +1,17 @@ +- ['Charging Chuck''s Helmet', [confident]] +- ['Koopa Soup', [well_fed]] +- ['Pea Switch', [well_fed]] +- ['Plain Donut', [well_fed]] +- ['Golden Warp Pipe', [energized]] +- ['Goomba Goo', [sluggish]] +- ['Wandering MechaKoopa', [startled]] +- ['An Unfortunately Placed Hidden Block', [startled]] +- ['Dinosaur Land Travel Brochure', []] +- ['Reznor Nail Clippings', []] +- ['Iggy Koopa''s Rubber Ball', []] +- ['Wendy Koopa''s Hula Hoop', []] +- ['Lemmy Koopa''s Decommissioned Doppelgangers', []] +- ['Larry Koopa''s Pet Block-snake', []] +- ['Morton Koopa''s Exercise Ball-and-Chain', []] +- ['Ludwig von Koopa''s Piano', []] +- ['Roy Koopa''s Backup Sunglasses', []] diff --git a/worlds/autopelago/items_by_game/Super Metroid.yml b/worlds/autopelago/items_by_game/Super Metroid.yml new file mode 100644 index 000000000000..c4a7d0b9523c --- /dev/null +++ b/worlds/autopelago/items_by_game/Super Metroid.yml @@ -0,0 +1,6 @@ +- ['Morphing Hamster Ball', [confident]] +- ['Soup or Missile', [well_fed, confident]] +- ['Mice Beam', [confident]] +- ['Lo-Jump Boots', [sluggish]] +- ['Wandering Metroid', [startled]] +- ['Empty E-Tank', []] diff --git a/worlds/autopelago/items_by_game/Yacht Dice.yml b/worlds/autopelago/items_by_game/Yacht Dice.yml new file mode 100644 index 000000000000..2c328e0a0087 --- /dev/null +++ b/worlds/autopelago/items_by_game/Yacht Dice.yml @@ -0,0 +1,5 @@ +- [['Block of Swiss Carved Like A Die', 'Ball of Spiders Carved Like A Die'], [well_fed]] +- ['Words of Discouragement', [sluggish]] +- ['Preposterously Small Straight', []] +- ['Two Yachts', []] +- ['Brand New Hats for Dice', []] diff --git a/worlds/autopelago/items_by_game/Yoshi's Island.yml b/worlds/autopelago/items_by_game/Yoshi's Island.yml new file mode 100644 index 000000000000..659f7c161e24 --- /dev/null +++ b/worlds/autopelago/items_by_game/Yoshi's Island.yml @@ -0,0 +1,5 @@ +- ['Barbecued Goonie Wings', [well_fed]] +- ['3 Shy Guy Omelette', [well_fed]] +- ['Touched Fuzzy, Got Dizzy', [distracted, sluggish]] +- ['Hungry Poochy', [startled]] +- ['Mouthful of Watermelon Seeds', []] diff --git a/worlds/autopelago/locations.py b/worlds/autopelago/locations.py new file mode 100644 index 000000000000..6a3b043709d4 --- /dev/null +++ b/worlds/autopelago/locations.py @@ -0,0 +1,157 @@ +from collections import deque + +from .definitions_types import ( + AutopelagoAllRequirement, + AutopelagoAnyRequirement, + AutopelagoAnyTwoRequirement, + AutopelagoGameRequirement, + AutopelagoItemRequirement, + AutopelagoNonProgressionItemType, + AutopelagoRegionDefinition, +) +from .items import item_key_to_name, item_name_groups, item_name_to_rat_count +from .util import defs, gen_ids + +autopelago_regions: dict[str, AutopelagoRegionDefinition] = {} +location_name_to_progression_item_name: dict[str, str] = {} +location_name_to_nonprogression_item: dict[str, AutopelagoNonProgressionItemType] = {} +location_name_to_requirement: dict[str, AutopelagoGameRequirement] = {} +location_name_to_id: dict[str, int] = {} +_location_id_gen = gen_ids() + +# build regions for landmarks +for k, curr_region in defs["regions"]["landmarks"].items(): + _name = curr_region["name"] + location_name_to_id[_name] = next(_location_id_gen) + location_name_to_progression_item_name[_name] = item_key_to_name[curr_region["unrandomized_item"]] + location_name_to_requirement[_name] = curr_region["requires"] + exits: list[str] = curr_region["exits"] if "exits" in curr_region else [] + autopelago_regions[k] = AutopelagoRegionDefinition(k, exits, [_name], curr_region["requires"], True) + +# build regions for fillers. these don't have any special requirements beyond region connections. +_no_requirement: AutopelagoAllRequirement = {"all": []} +for rk, curr_region in defs["regions"]["fillers"].items(): + _locations: list[str] = [] + _cur = 1 + region_items = curr_region["unrandomized_items"] + for k in region_items["key"] if "key" in region_items else []: + if isinstance(k, str): + # key: + # - pizza_rat + _name = curr_region["name_template"].replace("{n}", f"{_cur}") + location_name_to_id[_name] = next(_location_id_gen) + location_name_to_progression_item_name[_name] = item_key_to_name[k] + _locations.append(_name) + _cur += 1 + else: + # key: + # - item: pack_rat + # count: 5 + for _ in range(k["count"]): + _name = curr_region["name_template"].replace("{n}", f"{_cur}") + location_name_to_id[_name] = next(_location_id_gen) + location_name_to_progression_item_name[_name] = item_key_to_name[k["item"]] + _locations.append(_name) + _cur += 1 + for _ in range(region_items["useful_nonprogression"]) if "useful_nonprogression" in region_items else []: + _name = curr_region["name_template"].replace("{n}", f"{_cur}") + location_name_to_id[_name] = next(_location_id_gen) + location_name_to_nonprogression_item[_name] = "useful_nonprogression" + _locations.append(_name) + _cur += 1 + for _ in range(region_items["filler"]) if "filler" in region_items else []: + _name = curr_region["name_template"].replace("{n}", f"{_cur}") + location_name_to_id[_name] = next(_location_id_gen) + location_name_to_nonprogression_item[_name] = "filler" + _locations.append(_name) + _cur += 1 + autopelago_regions[rk] = AutopelagoRegionDefinition(rk, curr_region["exits"], _locations, _no_requirement, False) + + +def _get_required_rat_count(req: AutopelagoGameRequirement): + if "all" in req: + return max(_get_required_rat_count(sub_req) for sub_req in req["all"]) if req["all"] else 0 + if "any" in req: + return min(_get_required_rat_count(sub_req) for sub_req in req["any"]) + if "rat_count" in req: + return req["rat_count"] + return 0 + + +max_required_rat_count = max( + _get_required_rat_count(req) for req in + [location_name_to_requirement.values()] + + [r.requires for r in autopelago_regions.values()] +) +total_available_rat_count = sum( + item_name_to_rat_count[i] for i in + location_name_to_progression_item_name.values() + if i in item_name_to_rat_count +) + +location_name_groups: dict[str, set[str]] = { + "Landmarks": set(), + "Fillers": set(), + "Zone Bosses": set(), + "Sewer Landmarks": set(), + "Sewer Fillers": set(), + "Cool World Landmarks": set(), + "Cool World Fillers": set(), + "Space Landmarks": set(), + "Space Fillers": set(), + "Optional Bosses": set(), + "Optional Fillers": set(), +} + +def _visit_for_items(group_name: str, req: AutopelagoGameRequirement): + if "item" in req: + req: AutopelagoItemRequirement + item_name_groups[group_name].add(item_key_to_name[req["item"]]) + elif "all" in req: + req: AutopelagoAllRequirement + for sub_req in req["all"]: + _visit_for_items(group_name, sub_req) + elif "any" in req: + req: AutopelagoAnyRequirement + for sub_req in req["any"]: + _visit_for_items(group_name, sub_req) + elif "any_two" in req: + req: AutopelagoAnyTwoRequirement + for sub_req in req["any_two"]: + _visit_for_items(group_name, sub_req) + + +q: deque[tuple[str, AutopelagoRegionDefinition | None, AutopelagoRegionDefinition]] = deque() +q.append(("Sewer", None, autopelago_regions["before_basketball"])) +while q: + prev_region_from_q: AutopelagoRegionDefinition + curr_region_from_q: AutopelagoRegionDefinition + zone, prev_region_from_q, curr_region_from_q = q.popleft() + _visit_for_items(f"{zone} Progression", curr_region_from_q.requires) + region_type = "Landmarks" if curr_region_from_q.landmark else "Fillers" + if curr_region_from_q.key == "captured_goldfish": + region_type = "Zone Bosses" + next_zone = "Cool World" + elif curr_region_from_q.key == "secret_cache": + region_type = "Zone Bosses" + next_zone = "Space" + elif curr_region_from_q.key == "snakes_on_a_planet": + # nothing else needed beyond here + continue + else: + next_zone = zone + + next_regions = [autopelago_regions[e] for e in curr_region_from_q.exits] + for loc in curr_region_from_q.locations: + location_name_groups[region_type].add(loc) + if region_type != "Zone Bosses": + location_name_groups[f"{zone} {region_type}"].add(loc) + + if not next_regions: + location_name_groups["Optional Bosses"].add(loc) + for prev_loc in prev_region_from_q.locations: + location_name_groups["Optional Fillers"].add(prev_loc) + + if next_zone: + for next_region in next_regions: + q.append((next_zone, curr_region_from_q, next_region)) diff --git a/worlds/autopelago/options.py b/worlds/autopelago/options.py new file mode 100644 index 000000000000..c4afdccf9c7e --- /dev/null +++ b/worlds/autopelago/options.py @@ -0,0 +1,270 @@ +import logging +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ClassVar, Literal + +import yaml + +from Options import Choice, OptionList, OptionSet, PerGameCommonOptions, Range, Toggle, Visibility +from worlds.AutoWorld import World + +from .definitions_types import Aura + +if TYPE_CHECKING: + from Options import PlandoOptions + +FriendlyBuffNames = Literal["Well Fed", "Lucky", "Energized", "Stylish", "Confident", "Smart"] +buff_name_map: dict[FriendlyBuffNames, Aura] = { + "Well Fed": "well_fed", + "Lucky": "lucky", + "Energized": "energized", + "Stylish": "stylish", + "Confident": "confident", + "Smart": "smart", +} + +FriendlyTrapNames = Literal["Upset Tummy", "Unlucky", "Sluggish", "Distracted", "Startled", "Conspiratorial"] +trap_name_map: dict[FriendlyTrapNames, Aura] = { + "Upset Tummy": "upset_tummy", + "Unlucky": "unlucky", + "Sluggish": "sluggish", + "Distracted": "distracted", + "Startled": "startled", + "Conspiratorial": "conspiratorial", +} + +class FillWithDetermination(Toggle): + """Either fills the rat with determination, or does nothing. Perhaps both. + + This option was added early on for technical reasons. It does not directly affect the game.""" + display_name = "Fill With Determination" + + +class VictoryLocation(Choice): + """Optionally moves the final victory location earlier to reduce the number of locations in the multiworld. + + - **Snakes on a Planet (default):** The game goes all the way to "Moon, The". This gives the longest game. + - **Secret Cache:** The game stops at the end of Cool World. This gives the middlest-length game. + - **Captured Goldfish:** The game stops at the end of The Sewers. This gives the shortest game.""" + display_name = "Victory Location" + option_snakes_on_a_planet = 0 + option_secret_cache = 1 + option_captured_goldfish = 2 + default = 0 + + +class EnabledBuffs(OptionSet): + """Enables various buffs that affect how the rat behaves. All are enabled by default. + + - **Well Fed:** Gets more done + - **Lucky:** One free success + - **Energized:** Moves faster + - **Stylish:** Better RNG + - **Confident:** Ignore a trap + - **Smart:** Next check is progression""" + display_name = "Enabled Buffs" + value: frozenset[FriendlyBuffNames] + valid_keys = default = frozenset(buff_name_map.keys()) + map: ClassVar[dict[FriendlyBuffNames, Aura]] = buff_name_map + + +class EnabledTraps(OptionSet): + """Enables various traps that affect how the rat behaves. All are enabled by default. + + - **Upset Tummy:** Gets less done + - **Unlucky:** Worse RNG + - **Sluggish:** Moves slower + - **Distracted:** Skip a "step" + - **Startled:** Run towards start + - **Conspiratorial:** Next check is trap""" + display_name = "Enabled Traps" + value: frozenset[FriendlyTrapNames] + valid_keys = default = frozenset(trap_name_map.keys()) + map: ClassVar[dict[FriendlyTrapNames, Aura]] = trap_name_map + + +class DeathDelaySeconds(Range): + """Sets the delay (in seconds) from a death trigger to when the rat actually "dies". Has no effect if DeathLink is disabled. + + Default: 5 (seconds) + """ + display_name = "Death Link Delay" + range_start = 0 + range_end = 60 + default = 5 + + +# hack to avoid outputting the messages in flow style. it REALLY helps readability, which matters especially now before +# we have a GUI to edit these through the web app. +class RatChatMessagesHack: + items: list[tuple[str, int]] + def __init__(self, *args: str): + self.items = [(arg, 1) for arg in args] + + +# outputs lines in any of these formats. examples: +# - "Something that's got a single quote" +# - 'Something without single quotes (it may even have "double quotes"!)' +# - "Something that's got both \"single quotes\" and \"double quotes\". So obnoxious." +# - 'Something that you want to give 4x weight to, and without any quotes of either type': 4 +def represent_rat_chat_messages(_dumper: yaml.Dumper, data: RatChatMessagesHack): + return yaml.SequenceNode(tag="tag:yaml.org,2002:seq", value=[ + yaml.MappingNode(tag="tag:yaml.org,2002:map", value=[ + (yaml.ScalarNode(tag="tag:yaml.org,2002:str", value=t, style='"' if "'" in t else "'"), + yaml.ScalarNode(tag="tag:yaml.org,2002:int", value=f"{w}")) + ], flow_style=False) if w != 1 else + yaml.ScalarNode(tag="tag:yaml.org,2002:str", value=t, style='"' if "'" in t else "'") + for t, w in data.items + ]) + + +yaml.add_representer(RatChatMessagesHack, represent_rat_chat_messages) + + +class RatChatMessages(OptionList): + # I'm just using OptionList as a shortcut for most of the validation I need here. It won't actually work anywhere in + # either options UI, especially with the way I've got it replicating how other options can be weighted-or-not. + visibility = Visibility.template + + @classmethod + def from_any(cls, data: Any): + if isinstance(data, RatChatMessagesHack): + return super().from_any(data.items) + + if isinstance(data, Iterable): + res: list[tuple[str, int]] = [] + for t in data: + if isinstance(t, Mapping): + if len(t) != 1: + raise NotImplementedError(f"Dict must have only one item, got {len(t)}") + res += t.items() + elif isinstance(t, str): + res.append((t, 1)) + else: + raise NotImplementedError(f"Cannot convert from non-str + non-dict, got {type(t)}") + return super().from_any(res) + + raise NotImplementedError(f"Cannot convert from non-dict, got {type(data)}") + + def verify(self, world: type[World], player_name: str, plando_options: "PlandoOptions") -> None: + if len(self.value) == 0: + s = f"Settings file tried to set empty rat chat messages for {type(self).__name__} (player: {player_name})." + s += " This is not allowed. Reverting them to default." + logging.warning(s) + self.value = RatChatMessages.from_any(self.default).value + + +class ChangedTargetMessages(RatChatMessages): + """What messages the rat can say when a buff or trap is added to the queue of location checks to send before resuming its normal logic. + + Specify the message itself, or with an optional weight to have that message appear more often (default weight is 1). + + The text {LOCATION} will be replaced with the name of the actual location. + + If you want to disable rat chat, then you're in the wrong place. Do that from the settings menu in the game client itself.""" + display_name = "Messages - Changed Target" + + default = RatChatMessagesHack( + "Oh, hey, what's that thing over there at {LOCATION}?", + "There's something at {LOCATION}, I'm sure of it!", + "Something at {LOCATION} smells good!", + "There's a rumor that something's going on at {LOCATION}!", + ) + + +class EnterGoModeMessages(RatChatMessages): + """What messages the rat can say when it first realizes that it can complete its goal. + + Specify the message itself, or with an optional weight to have that message appear more often (default weight is 1). + + If you want to disable rat chat, then you're in the wrong place. Do that from the settings menu in the game client itself.""" + display_name = "Messages - Go Mode" + + default = RatChatMessagesHack( + "That's it! I have everything I need! The goal is in sight!", + ) + + +class EnterBKModeMessages(RatChatMessages): + """What messages the rat can say when it first sees that no further location checks are in logic. + + Specify the message itself, or with an optional weight to have that message appear more often (default weight is 1). + + If you want to disable rat chat, then you're in the wrong place. Do that from the settings menu in the game client itself.""" + display_name = "Messages - Enter BK Mode" + + default = RatChatMessagesHack( + "I don't have anything to do right now. Go team!", + "Hey, I'm completely stuck. But I still believe in you!", + "I've run out of things to do. How are you?", + "I'm out of things for now, gonna get a coffee. Anyone want something?", + ) + + +class RemindBKModeMessages(RatChatMessages): + """What messages the rat can say to occasionally remind the players that it has no further location checks in logic. + + Specify the message itself, or with an optional weight to have that message appear more often (default weight is 1). + + If you want to disable rat chat, then you're in the wrong place. Do that from the settings menu in the game client itself.""" + display_name = "Messages - Still BK" + + default = RatChatMessagesHack( + "I don't have anything to do right now. Go team!", + "Hey, I'm completely stuck. But I still believe in you!", + "I've run out of things to do. How are you?", + "I'm out of things for now, gonna get a coffee. Anyone want something?", + ) + + +class ExitBKModeMessages(RatChatMessages): + """What messages the rat can say after one or more location checks become in logic. + + Specify the message itself, or with an optional weight to have that message appear more often (default weight is 1). + + If you want to disable rat chat, then you're in the wrong place. Do that from the settings menu in the game client itself.""" + display_name = "Messages - Exit BK" + + default = RatChatMessagesHack( + "Yippee, that's just what I needed!", + "I'm back! I knew you could do it!", + "Sweet, I'm unblocked! Thanks!", + "Squeak-squeak, it's rattin' time!", + ) + + +class CompleteGoalMessages(RatChatMessages): + """What messages the rat can say to celebrate victory. + + Specify the message itself, or with an optional weight to have that message appear more often (default weight is 1). + + If you want to disable rat chat, then you're in the wrong place. Do that from the settings menu in the game client itself.""" + display_name = "Messages - Victory" + + default = RatChatMessagesHack( + "Yeah, I did it! er... WE did it!", + ) + + +class LactoseIntolerantMode(Toggle): + """Replaces all references to lactose-containing products with less offensive ones.""" + display_name = "Lactose Intolerant Mode" + + +@dataclass +class AutopelagoGameOptions(PerGameCommonOptions): + fill_with_determination: FillWithDetermination + victory_location: VictoryLocation + enabled_buffs: EnabledBuffs + enabled_traps: EnabledTraps + msg_changed_target: ChangedTargetMessages + msg_enter_go_mode: EnterGoModeMessages + msg_enter_bk: EnterBKModeMessages + msg_remind_bk: RemindBKModeMessages + msg_exit_bk: ExitBKModeMessages + msg_completed_goal: CompleteGoalMessages + lactose_intolerant: LactoseIntolerantMode + + # not working yet: + # death_link: DeathLink + # death_delay_seconds: DeathDelaySeconds diff --git a/worlds/autopelago/test/__init__.py b/worlds/autopelago/test/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/worlds/autopelago/test/test_access.py b/worlds/autopelago/test/test_access.py new file mode 100644 index 000000000000..d4020bb1577b --- /dev/null +++ b/worlds/autopelago/test/test_access.py @@ -0,0 +1,176 @@ +from collections.abc import Iterable +from typing import TypedDict, TypeVar + +from test.bases import WorldTestBase +from typing_extensions import Unpack + +T = TypeVar("T") + + +def all_pairwise_combinations(*possible_items: T) -> Iterable[tuple[T, T]]: + for i in range(1, len(possible_items)): + second = possible_items[i] + for j in range(i): + first = possible_items[j] + yield first, second + + +class CollectKwargs(TypedDict): + collect: bool | None + + +class AccessTest(WorldTestBase): + game = "Autopelago" + + def setUp(self): + super().setUp() + self.pack_rat = self.world.create_item("Pack Rat") + self.rat_pack = self.world.create_item("Rat Pack") + + def collect_rats(self, rat_count: int): + self.collect(self.pack_rat for _ in range(rat_count)) + + def remove_rats(self, rat_count: int): + self.remove(self.pack_rat for _ in range(rat_count)) + + def assert_and_collect_access_dependency_from_here(self, location: str, *possible_items: str | list[str], + **kwargs: Unpack[CollectKwargs]): + for original_item_list in possible_items: + item_list = (original_item_list,) if original_item_list is str else original_item_list + self.assertFalse(self.can_reach_location(location)) + self.collect_by_name(item_list) + self.assertTrue(self.can_reach_location(location)) + self.remove_by_name(item_list) + if ("collect" not in kwargs) or kwargs["collect"]: + self.collect_by_name(possible_items[0]) + + def assert_and_collect_additional_rats_from_here(self, location: str, rat_count: int, collect=True): + self.collect_rats(max(0, rat_count - 5)) + self.assertFalse(self.can_reach_location(location)) + self.collect(self.rat_pack) + self.assertTrue(self.can_reach_location(location)) + self.remove(self.rat_pack) + self.collect_rats(min(rat_count, 5)) + self.assertTrue(self.can_reach_location(location)) + if not collect: + self.remove_rats(rat_count) + + def test_top_branches(self) -> None: + # World 1 + self.assert_and_collect_additional_rats_from_here( + "Basketball", 5) + self.assert_and_collect_access_dependency_from_here( + "Prawn Stars", "Premium Can of Prawn Food", "Priceless Antique") + self.assert_and_collect_access_dependency_from_here( + "Angry Turtles", "Pizza Rat", "Ninja Rat", collect=False) + self.assert_and_collect_access_dependency_from_here( + "Pirate Bake Sale", "Pie Rat") + self.assert_and_collect_additional_rats_from_here( + "Bowling Ball Door", 4) + self.assert_and_collect_access_dependency_from_here( + "Captured Goldfish", "Giant Novelty Scissors") + + # World 2 + self.assert_and_collect_access_dependency_from_here( + "Computer Interface", "Computer Rat") + self.assert_and_collect_access_dependency_from_here( + "Kart Races", "Blue Turtle Shell", "Banana Peel") + self.assert_and_collect_access_dependency_from_here( + "Daring Adventurer", "Masterful Longsword", "MacGuffin", collect=False) + self.assert_and_collect_additional_rats_from_here( + "Broken-Down Bus", 14) + self.assert_and_collect_access_dependency_from_here( + "Copyright Mouse", "Fake Mouse Ears", "Legally Binding Contract") + self.assert_and_collect_additional_rats_from_here( + "Room Full of Typewriters", 12, collect=False) + self.assert_and_collect_access_dependency_from_here( + "Binary Tree", "Child's First Hand Axe") + self.assert_and_collect_access_dependency_from_here( + "Rat Rap Battle", "Fifty Cents", "Notorious R.A.T.") + self.assert_and_collect_access_dependency_from_here( + "Secret Cache", "Virtual Key", "Map of the Entire Internet") + + # World 3 + self.assert_and_collect_access_dependency_from_here( + "Makeshift Rocket Ship", *all_pairwise_combinations( + "Energy Drink that is Pure Rocket Fuel", "Pile of Scrap Metal in the Shape of a Rocket Ship", + "Ratstronaut", "Turbo Encabulator")) + self.assert_and_collect_access_dependency_from_here( + "Robo-Clop: The Robot War Horse", "Quantum Sugar Cube") + self.assert_and_collect_access_dependency_from_here( + "Homeless Mummy", "Pharaoh-Not Anti-Mummy Spray", "Ziggu Rat", collect=False) + self.assert_and_collect_additional_rats_from_here( + "Stalled Rocket", 15) + self.assert_and_collect_access_dependency_from_here( + "Seal of Fortune", "Constellation Prize", "Free Vowel") + self.assert_and_collect_additional_rats_from_here( + "Space Opera", 9) + self.assert_and_collect_access_dependency_from_here( + "Minotaur Labyrinth", "Red Matador's Cape", "Lab Rat", collect=False) + self.assertBeatable(False) + self.assert_and_collect_access_dependency_from_here( + "Snakes on a Planet", "Mongoose in a Combat Spacecraft") + self.assertBeatable(True) + + def test_bottom_branches(self) -> None: + # World 1 + self.assert_and_collect_additional_rats_from_here( + "Basketball", 5) + self.assert_and_collect_access_dependency_from_here( + "Angry Turtles", "Pizza Rat", "Ninja Rat") + self.assert_and_collect_access_dependency_from_here( + "Prawn Stars", "Premium Can of Prawn Food", "Priceless Antique", collect=False) + self.assert_and_collect_access_dependency_from_here( + "Restaurant", "Chef Rat") + self.assert_and_collect_additional_rats_from_here( + "Bowling Ball Door", 3) + self.assert_and_collect_access_dependency_from_here( + "Captured Goldfish", "Giant Novelty Scissors") + + # World 2 + self.assert_and_collect_access_dependency_from_here( + "Computer Interface", "Computer Rat") + self.assert_and_collect_access_dependency_from_here( + "Daring Adventurer", "Masterful Longsword", "MacGuffin") + self.assert_and_collect_access_dependency_from_here( + "Kart Races", "Blue Turtle Shell", "Banana Peel", collect=False) + self.assert_and_collect_additional_rats_from_here( + "Overweight Boulder", 14) + self.assert_and_collect_access_dependency_from_here( + "Blue-Colored Screen Interface", "Lost CTRL Key", "Hammer of Problem-Solving") + self.assert_and_collect_access_dependency_from_here( + "Trapeze", "Acro Rat", collect=False) + self.assert_and_collect_access_dependency_from_here( + "Computer Ram", "Artificial Grass") + self.assert_and_collect_access_dependency_from_here( + "Stack of Crates", "Forklift Certification", "Gym Rat") + self.assert_and_collect_access_dependency_from_here( + "Secret Cache", "Virtual Key", "Map of the Entire Internet") + + # World 3 + self.assert_and_collect_access_dependency_from_here( + "Makeshift Rocket Ship", *all_pairwise_combinations( + "Energy Drink that is Pure Rocket Fuel", "Pile of Scrap Metal in the Shape of a Rocket Ship", + "Ratstronaut", "Turbo Encabulator")) + self.assert_and_collect_access_dependency_from_here( + "Robo-Clop: The Robot War Horse", "Quantum Sugar Cube") + self.assert_and_collect_additional_rats_from_here( + "Stalled Rocket", 15, collect=False) + self.assert_and_collect_access_dependency_from_here( + "Homeless Mummy", "Pharaoh-Not Anti-Mummy Spray", "Ziggu Rat") + self.assert_and_collect_access_dependency_from_here( + "Frozen Assets", "Playing with Fire For Dummies", collect=False) + self.assert_and_collect_access_dependency_from_here( + "Alien Vending Machine", "Foreign Coin", collect=False) + self.assert_and_collect_access_dependency_from_here( + "Seal of Fortune", "Constellation Prize", "Free Vowel") + self.assert_and_collect_access_dependency_from_here( + "Minotaur Labyrinth", "Red Matador's Cape", "Lab Rat") + self.assert_and_collect_additional_rats_from_here( + "Space Opera", 24, collect=False) + self.assert_and_collect_access_dependency_from_here( + "Asteroid with Pants", "Asteroid Belt", "Moon Shaped Like a Butt", collect=False) + self.assertBeatable(False) + self.assert_and_collect_access_dependency_from_here( + "Snakes on a Planet", "Mongoose in a Combat Spacecraft") + self.assertBeatable(True) diff --git a/worlds/autopelago/test/test_easter_egg_items.py b/worlds/autopelago/test/test_easter_egg_items.py new file mode 100644 index 000000000000..4e9e9276fbce --- /dev/null +++ b/worlds/autopelago/test/test_easter_egg_items.py @@ -0,0 +1,29 @@ +from typing import TypeVar + +from test.bases import WorldTestBase +from test.general import setup_multiworld + +from worlds.autopelago import AutopelagoWorld +from worlds.yachtdice import YachtDiceWorld + +T = TypeVar("T") + + +class EasterEggItemTestWhenYachtDiceAbsent(WorldTestBase): + game = "Autopelago" + run_default_tests = False + + def test_without_yacht_dice(self) -> None: + self.assertNotIn("Two Yachts", (item.name for item in self.multiworld.get_items())) + + +class EasterEggItemTestWhenYachtDicePresent(WorldTestBase): + auto_construct = False + run_default_tests = False + + def setUp(self): + self.multiworld = setup_multiworld([AutopelagoWorld, YachtDiceWorld]) + super().setUp() + + def test_with_yacht_dice(self) -> None: + self.assertIn("Two Yachts", (item.name for item in self.multiworld.get_items())) diff --git a/worlds/autopelago/test/test_item_classification.py b/worlds/autopelago/test/test_item_classification.py new file mode 100644 index 000000000000..49039e4efc90 --- /dev/null +++ b/worlds/autopelago/test/test_item_classification.py @@ -0,0 +1,57 @@ +from BaseClasses import ItemClassification +from test.bases import WorldTestBase +from worlds.autopelago import GAME_NAME, item_name_to_auras + + +class ItemClassificationTestWithOnlyWellFedAuraEnabled(WorldTestBase): + game = GAME_NAME + run_default_tests = False + def setUp(self): + self.options = { + "enabled_buffs": frozenset({"Well Fed"}), + "enabled_traps": frozenset(), + } + super().setUp() + + def test_proper_classifications(self) -> None: + for item in self.multiworld.get_items(): + self.assertNotIn(ItemClassification.trap, item.classification) + if "well_fed" in item_name_to_auras[item.name]: + self.assertIn(ItemClassification.useful, item.classification) + else: + self.assertNotIn(ItemClassification.useful, item.classification, item) + + +class ItemClassificationTestWithOnlyStartledTrapEnabled(WorldTestBase): + game = GAME_NAME + run_default_tests = False + def setUp(self): + self.options = { + "enabled_buffs": frozenset(), + "enabled_traps": frozenset({"Startled"}), + } + super().setUp() + + def test_proper_classifications(self) -> None: + for item in self.multiworld.get_items(): + self.assertNotIn(ItemClassification.useful, item.classification) + if "startled" in item_name_to_auras[item.name]: + self.assertIn(ItemClassification.trap, item.classification) + else: + self.assertNotIn(ItemClassification.trap, item.classification, item) + + +class ItemClassificationTestWithNoAurasEnabled(WorldTestBase): + game = GAME_NAME + run_default_tests = False + def setUp(self): + self.options = { + "enabled_buffs": frozenset(), + "enabled_traps": frozenset(), + } + super().setUp() + + def test_proper_classifications(self) -> None: + for item in self.multiworld.get_items(): + self.assertNotIn(ItemClassification.useful, item.classification, item) + self.assertNotIn(ItemClassification.trap, item.classification, item) \ No newline at end of file diff --git a/worlds/autopelago/util.py b/worlds/autopelago/util.py new file mode 100644 index 000000000000..7cd4fda3cf55 --- /dev/null +++ b/worlds/autopelago/util.py @@ -0,0 +1,15 @@ +import pkgutil + +from Utils import parse_yaml + +from .definitions_types import AutopelagoDefinitions + +GAME_NAME = "Autopelago" +defs: AutopelagoDefinitions = parse_yaml(pkgutil.get_data(__name__, "AutopelagoDefinitions.yml")) + + +def gen_ids(): + next_id = 1 + while True: + yield next_id + next_id += 1