diff --git a/playerSettings.yaml b/playerSettings.yaml index bfec9c152a..15375cf821 100644 --- a/playerSettings.yaml +++ b/playerSettings.yaml @@ -29,6 +29,7 @@ game: # Pick a game to play Minecraft: 0 Subnautica: 0 Slay the Spire: 0 + Terraria: 0 requires: version: 0.1.6 # Version of Archipelago required for this yaml to work as expected. # Shared Options supported by all games: @@ -279,6 +280,18 @@ Minecraft: send_defeated_mobs: # Send killed mobs to other Minecraft worlds which have this option enabled. on: 0 off: 1 +Terraria: + include_hardmode_achievements: # Junk-fills achievements which can only be obtained after defeating the Wall of Flesh. + on: 0 + off: 1 + # Junk-fills extremely difficult advancements; this is primarily for achievements which require completing large parts of the game + # (such as gelatin world tour or Real Estate Agent) as well as the more advanced fishing quests. + include_insane_achievements: + on: 0 + off: 1 + include_postgame_achievements: # Some achievements require defeating the Moon Lord first; this will junk-fill them so you won't have to finish to send some items. + on: 0 + off: 1 A Link to the Past: ### Logic Section ### glitches_required: # Determine the logic required to complete the seed diff --git a/worlds/terraria/Items.py b/worlds/terraria/Items.py new file mode 100644 index 0000000000..6e3e965c55 --- /dev/null +++ b/worlds/terraria/Items.py @@ -0,0 +1,26 @@ +from BaseClasses import Item +import typing + + +class ItemData(typing.NamedTuple): + code: typing.Optional[int] + progression: bool + + +class TerrariaItem(Item): + game: str = "Terraria" + + +item_table = { + "Copper Shortsword": ItemData(73001, False), + + "Victory": ItemData(73000, True) +} + +# If not listed here then has frequency 1 +item_frequencies = { + "Copper Shortsword": 87, + +} + +lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code} diff --git a/worlds/terraria/Locations.py b/worlds/terraria/Locations.py new file mode 100644 index 0000000000..01c3ebcea2 --- /dev/null +++ b/worlds/terraria/Locations.py @@ -0,0 +1,166 @@ +from BaseClasses import Location +import typing + + +class AchieveData(typing.NamedTuple): + id: typing.Optional[int] + region: str + + +class TerrariaAchievement(Location): + game: str = "Terraria" + + def __init__(self, player: int, name: str, address: typing.Optional[int], parent): + super().__init__(player, name, address, parent) + self.event = not address + + +achievement_table = { + "Timber!!": AchieveData(0, "Overworld"), + "No Hobo": AchieveData(1, "Overworld"), + "Stop! Hammer Time!": AchieveData(2, "Overworld"), + "Ooo! Shiny!": AchieveData(3, "Overworld"), + "Heart Breaker": AchieveData(4, "Overworld"), + "Heavy Metal": AchieveData(5, "Overworld"), + "I Am Loot!": AchieveData(6, "Overworld"), + "Star Power": AchieveData(7, "Overworld"), + "Hold on Tight!": AchieveData(8, "Overworld"), + "Eye on You": AchieveData(9, "Overworld"), + "Smashing, Poppet!": AchieveData(10, "Overworld"), + "Worm Fodder": AchieveData(11, "Corruption"), + "Mastermind": AchieveData(12, "Crimson"), + "Where's My Honey?": AchieveData(13, "Jungle"), + "Sting Operation": AchieveData(14, "Jungle"), + "Boned": AchieveData(15, "Overworld"), + "Dungeon Heist": AchieveData(16, "Dungeon"), + "It's Getting Hot in Here": AchieveData(17, "Underworld"), + "Miner for Fire": AchieveData(18, "Underworld"), + "Still Hungry": AchieveData(19, "Underworld"), + "It's Hard!": AchieveData(20, "Underworld"), + "Begone, Evil!": AchieveData(21, "Hardmode"), + "Extra Shiny!": AchieveData(22, "Hardmode"), + "Head in the Clouds": AchieveData(23, "Hardmode"), + "Like a Boss": AchieveData(24, "Overworld"), + "Buckets of Bolts": AchieveData(25, "Hardmode"), + "Drax Attax": AchieveData(26, "Hardmode"), + "Photosynthesis": AchieveData(27, "Hardmode Jungle"), + "Get a Life": AchieveData(28, "Hardmode Jungle"), + "The Great Southern Plantkill": AchieveData(29, "Hardmode Jungle"), + "Temple Raider": AchieveData(30, "Post-Plantera"), + "Lihzahrdian Idol": AchieveData(31, "Post-Plantera"), + "Robbing the Grave": AchieveData(32, "Post-Plantera"), + "Big Booty": AchieveData(33, "Post-Plantera"), + "Fish Out of Water": AchieveData(34, "Overworld"), + "Obsessive Devotion": AchieveData(35, "Post-Golem"), + "Star Destroyer": AchieveData(36, "Post-Golem"), + "Champion of Terraria": AchieveData(37, "Post-Golem"), + "Bloodbath": AchieveData(38, "Overworld"), + "Slippery Shinobi": AchieveData(39, "Overworld"), + "Goblin Punter": AchieveData(40, "Overworld"), + "Walk the Plank": AchieveData(41, "Hardmode"), + "Kill the Sun": AchieveData(42, "Hardmode"), + "Do You Want to Slay a Snowman?": AchieveData(43, "Hardmode"), + "Tin-Foil Hatter": AchieveData(44, "Post-Golem"), + "Baleful Harvest": AchieveData(45, "Post-Plantera"), + "Ice Scream": AchieveData(46, "Post-Plantera"), + "Sticky Situation": AchieveData(47, "Overworld"), + "Real Estate Agent": AchieveData(48, "Postgame"), + "Not the Bees!": AchieveData(49, "Jungle"), + "Jeepers Creepers": AchieveData(50, "Overworld"), + "Funkytown": AchieveData(51, "Overworld"), + "Into Orbit": AchieveData(52, "Overworld"), + "Rock Bottom": AchieveData(53, "Underworld"), + "Mecha Mayhem": AchieveData(54, "Hardmode"), + "Gelatin World Tour": AchieveData(55, "Postgame"), + "Fashion Statement": AchieveData(56, "Overworld"), + "Vehicular Manslaughter": AchieveData(57, "Overworld"), + "Bulldozer": AchieveData(58, "Overworld"), + "There are Some Who Call Him...": AchieveData(59, "Overworld"), + "Deceiver of Fools": AchieveData(60, "Overworld"), + "Sword of the Hero": AchieveData(61, "Hardmode"), + "Lucky Break": AchieveData(62, "Overworld"), + "Throwing Lines": AchieveData(63, "Overworld"), + "Dye Hard": AchieveData(64, "Overworld"), + "Sick Throw": AchieveData(65, "Postgame"), + "The Frequent Flyer": AchieveData(66, "Overworld"), + "The Cavalry": AchieveData(67, "Overworld"), + "Completely Awesome": AchieveData(68, "Overworld"), + "Til Death...": AchieveData(69, "Overworld"), + "Archaeologist": AchieveData(70, "Jungle"), + "Pretty in Pink": AchieveData(71, "Overworld"), + "Rainbows and Unicorns": AchieveData(72, "Hardmode"), + "You and What Army?": AchieveData(73, "Hardmode"), + "Prismancer": AchieveData(74, "Hardmode"), + "It Can Talk?!": AchieveData(75, "Hardmode"), + "Watch Your Step!": AchieveData(76, "Overworld"), + "Marathon Medalist": AchieveData(77, "Overworld"), + "Glorious Golden Pole": AchieveData(78, "Overworld"), + "Servant-in-Training": AchieveData(79, "Overworld"), + "Good Little Slave": AchieveData(80, "Overworld"), + "Trout Monkey": AchieveData(81, "Overworld"), + "Fast and Fishious": AchieveData(82, "Overworld"), + "Supreme Helper Minion!": AchieveData(83, "Overworld"), + "Topped Off": AchieveData(84, "Hardmode"), + "Slayer of Worlds": AchieveData(85, "Postgame"), + "You Can Do It!": AchieveData(86, "Overworld"), + "Matching Attire": AchieveData(87, "Overworld"), +} + +exclusion_table = { + "hardmode": { + "It's Hard!", + "Extra Shiny!", + "Head in the Clouds", + "Buckets of Bolts", + "Drax Attax", + "Photosynthesis", + "Get a Life", + "The Great Southern Plantkill", + "Temple Raider", + "Lihzahrdian Idol", + "Robbing the Grave", + "Big Booty", + "Fish Out of Water", + "Obsessive Devotion", + "Star Destroyer", + "Champion of Terraria", + "Walk the Plank", + "Kill the Sun", + "Do You Want to Slay a Snowman?", + "Tin-Foil Hatter", + "Baleful Harvest", + "Ice Scream", + "Real Estate Agent", + "Mecha Mayhem", + "Gelatin World Tour", + "Sword of the Hero", + "Sick Throw", + "Rainbows and Unicorns", + "You and What Army?", + "Prismancer", + "It Can Talk?!", + "Topped Off", + "Slayer of Worlds", + }, + "insane": { + "Gelatin World Tour", + "Fast and Fishious", + "Supreme Helper Minion!", + "Real Estate Agent", + "Mecha Mayhem", + "Bulldozer", + "Marathon Medalist", + "Slayer of Worlds", + }, + "postgame": { + "Slayer of Worlds", + "Sick Throw", + } +} + +events_table = { + "Still Hungry": "Victory" +} + +lookup_id_to_name: typing.Dict[int, str] = {loc_data.id: loc_name for loc_name, loc_data in achievement_table.items() if + loc_data.id} diff --git a/worlds/terraria/Options.py b/worlds/terraria/Options.py new file mode 100644 index 0000000000..967607cd53 --- /dev/null +++ b/worlds/terraria/Options.py @@ -0,0 +1,8 @@ +import typing +from Options import Choice, Option, Toggle, Range + +terraria_options: typing.Dict[str, type(Option)] = { + "include_hardmode_achievements": Toggle, + "include_insane_achievements": Toggle, + "include_postgame_achievements": Toggle, +} \ No newline at end of file diff --git a/worlds/terraria/Regions.py b/worlds/terraria/Regions.py new file mode 100644 index 0000000000..413502dc17 --- /dev/null +++ b/worlds/terraria/Regions.py @@ -0,0 +1,85 @@ + +def link_terraria_structures(world, player): + + # Link mandatory connections first + for (exit, region) in mandatory_connections: + world.get_entrance(exit, player).connect(world.get_region(region, player)) + + # Get all unpaired exits and all regions without entrances (except the Menu) + # This function is destructive on these lists. + exits = [exit.name for r in world.regions if r.player == player for exit in r.exits if exit.connected_region == None] + structs = [r.name for r in world.regions if r.player == player and r.entrances == [] and r.name != 'Menu'] + exits_spoiler = exits[:] # copy the original order for the spoiler log + #try: + # assert len(exits) == len(structs) + #except AssertionError as e: # this should never happen + # raise Exception(f"Could not obtain equal numbers of Minecraft exits and structures for player {player} ({world.player_names[player]})") + + pairs = {} + + def set_pair(exit, struct): + if (exit in exits) and (struct in structs) and (exit not in illegal_connections.get(struct, [])): + pairs[exit] = struct + exits.remove(exit) + structs.remove(struct) + else: + raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({world.player_names[player]})") + + + for (exit, struct) in default_connections: + if exit in exits: + set_pair(exit, struct) + + # Make sure we actually paired everything; might fail if plando + try: + assert len(exits) == len(structs) == 0 + except AssertionError: + raise Exception(f"Failed to connect all Minecraft structures for player {player} ({world.player_names[player]})") + + for exit in exits_spoiler: + world.get_entrance(exit, player).connect(world.get_region(pairs[exit], player)) + if world.shuffle_structures[player] or world.plando_connections[player]: + world.spoiler.set_entrance(exit, pairs[exit], 'entrance', player) + + + +# (Region name, list of exits) +terraria_regions = [ + ('Menu', ['New World']), + ('Overworld', ['Descend to Underworld', 'Go to Jungle', 'Go to Dungeon', 'Go to Corruption', 'Go to Crimson']), + ('Underworld', ['Kill WoF']), + ('Jungle', []), + ('Corruption', []), + ('Crimson', []), + ('Dungeon', []), + ('Hardmode Jungle', []), + ('Hardmode', ['Kill Plantera', 'Go to Hardmode Jungle']), + ('Post-Plantera', ['Kill Golem']), + ('Post-Golem', ['Kill Moon Lord']), + ('Postgame', []) +] + +# (Entrance, region pointed to) +mandatory_connections = [ + ('New World', 'Overworld'), + ('Descend to Underworld', 'Underworld'), + ('Go to Jungle', 'Jungle'), + ('Go to Corruption', 'Corruption'), + ('Go to Crimson', 'Crimson'), + ('Go to Hardmode Jungle', 'Hardmode Jungle'), + ('Go to Dungeon', 'Dungeon'), + ('Kill WoF', 'Hardmode'), + ('Kill Plantera', 'Post-Plantera'), + ('Kill Golem', 'Post-Golem'), + ('Kill Moon Lord', 'Postgame'), +] + +default_connections = { + +} + +# Structure: illegal locations +illegal_connections = { + +} + diff --git a/worlds/terraria/Rules.py b/worlds/terraria/Rules.py new file mode 100644 index 0000000000..015d4944a7 --- /dev/null +++ b/worlds/terraria/Rules.py @@ -0,0 +1,43 @@ +from ..generic.Rules import set_rule +from .Locations import exclusion_table, events_table +from BaseClasses import MultiWorld +from ..AutoWorld import LogicMixin + + +class TerrariaLogic(LogicMixin): + # Defs here + + def temp(self, player: int): + pass + +def set_rules(world: MultiWorld, player: int): + def reachable_locations(state): + postgame_advancements = exclusion_table['postgame'].copy() + for event in events_table.keys(): + postgame_advancements.add(event) + return [location for location in world.get_locations() if + location.player == player and + location.name not in postgame_advancements and + location.can_reach(state)] + + # 88 total achievements. Goal is to defeat Wall of Flesh. + goal = 20#int(world.achievement_goal[player].value) + can_complete = lambda state: len(reachable_locations(state)) >= goal and state.can_reach('Hardmode', 'Region', player) + + if world.logic[player] != 'nologic': + world.completion_condition[player] = lambda state: state.has('Victory', player) + + set_rule(world.get_entrance("Kill WoF", player), lambda state: True) + set_rule(world.get_entrance("Kill Plantera", player), lambda state: True) + set_rule(world.get_entrance("Kill Golem", player), lambda state: True) + set_rule(world.get_entrance("Kill Moon Lord", player), lambda state: True) + set_rule(world.get_entrance("Descend to Underworld", player), lambda state: True) + set_rule(world.get_entrance("Go to Corruption", player), lambda state: True) + set_rule(world.get_entrance("Go to Crimson", player), lambda state: True) + set_rule(world.get_entrance("Go to Dungeon", player), lambda state: True) + set_rule(world.get_entrance("Go to Jungle", player), lambda state: True) + set_rule(world.get_entrance("Go to Hardmode Jungle", player), lambda state: True) + + set_rule(world.get_location("Still Hungry", player), lambda state: can_complete(state)) + + \ No newline at end of file diff --git a/worlds/terraria/__init__.py b/worlds/terraria/__init__.py new file mode 100644 index 0000000000..3d94c0c89c --- /dev/null +++ b/worlds/terraria/__init__.py @@ -0,0 +1,99 @@ +import os + + +from .Items import TerrariaItem, item_table, item_frequencies +from .Locations import TerrariaAchievement, achievement_table, exclusion_table, events_table +from .Regions import terraria_regions, link_terraria_structures, default_connections +from .Rules import set_rules +from worlds.generic.Rules import exclusion_rules + +from BaseClasses import Region, Entrance, Item +from .Options import terraria_options +from ..AutoWorld import World + +client_version = 5 + +class TerrariaWorld(World): + game: str = "Terraria" + options = terraria_options + topology_present = True + + item_name_to_id = {name: data.code for name, data in item_table.items()} + location_name_to_id = {name: data.id for name, data in achievement_table.items()} + + data_version = 2 + + def _get_terraria_data(self): + exits = [connection[0] for connection in default_connections] + return { + 'world_seed': self.world.slot_seeds[self.player].getrandbits(32), + # consistent and doesn't interfere with other generation + 'seed_name': self.world.seed_name, + 'player_name': self.world.get_player_name(self.player), + 'player_id': self.player, + 'client_version': client_version, + 'race': self.world.is_race + } + + def generate_basic(self): + + # Generate item pool + itempool = [] + pool_counts = item_frequencies.copy() + for item_name in item_table: + for count in range(pool_counts.get(item_name, 1)): + itempool.append(self.create_item(item_name)) + + # Choose locations to automatically exclude based on settings + exclusion_pool = set() + exclusion_types = ['hardmode', 'insane', 'postgame'] + for key in exclusion_types: + if not getattr(self.world, f"include_{key}_achievements")[self.player]: + exclusion_pool.update(exclusion_table[key]) + exclusion_rules(self.world, self.player, exclusion_pool) + + # Prefill the Wall of Flesh with the completion condition + completion = self.create_item("Victory") + self.world.get_location("Still Hungry", self.player).place_locked_item(completion) + itempool.remove(completion) + self.world.itempool += itempool + + def set_rules(self): + set_rules(self.world, self.player) + + def create_regions(self): + def TerrariaRegion(region_name: str, exits=[]): + ret = Region(region_name, None, region_name, self.player, self.world) + ret.locations = [TerrariaAchievement(self.player, loc_name, loc_data.id, ret) + for loc_name, loc_data in achievement_table.items() + if loc_data.region == region_name] + for exit in exits: + ret.exits.append(Entrance(self.player, exit, ret)) + return ret + + self.world.regions += [TerrariaRegion(*r) for r in terraria_regions] + link_terraria_structures(self.world, self.player) + + def generate_output(self, output_directory: str): + import json + from base64 import b64encode + + data = self._get_terraria_data() + filename = f"AP_{self.world.seed_name}_P{self.player}_{self.world.get_player_name(self.player)}.apterra" + with open(os.path.join(output_directory, filename), 'wb') as f: + f.write(b64encode(bytes(json.dumps(data), 'utf-8'))) + + def fill_slot_data(self): + slot_data = self._get_terraria_data() + for option_name in terraria_options: + option = getattr(self.world, option_name)[self.player] + slot_data[option_name] = int(option.value) + return slot_data + + def create_item(self, name: str) -> Item: + item_data = item_table[name] + item = TerrariaItem(name, item_data.progression, item_data.code, self.player) + nonexcluded_items = [] + if name in nonexcluded_items: # prevent books from going on excluded locations + item.never_exclude = True + return item