diff --git a/.gitignore b/.gitignore index 5f8ad6b917e5..14b786ef73da 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ *.apsave *.BIN +setups build bundle/components.wxs dist @@ -176,6 +177,9 @@ minecraft_versions.json # pyenv .python-version +#undertale stuff +/Undertale/ + # OS General Files .DS_Store .AppleDouble diff --git a/README.md b/README.md index 654cd6d6000f..135059b726b2 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Currently, the following games are supported: * Adventure * DLC Quest * Noita +* Undertale For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/UndertaleClient.py b/UndertaleClient.py new file mode 100644 index 000000000000..d30cb8446261 --- /dev/null +++ b/UndertaleClient.py @@ -0,0 +1,498 @@ +from __future__ import annotations +import os +import asyncio +import typing +import bsdiff4 +import shutil + +import Utils + +from NetUtils import NetworkItem, ClientStatus +from worlds import undertale +from MultiServer import mark_raw +from CommonClient import CommonContext, server_loop, \ + gui_enabled, ClientCommandProcessor, get_base_parser +from Utils import async_start + + +class UndertaleCommandProcessor(ClientCommandProcessor): + def __init__(self, ctx): + super().__init__(ctx) + + def _cmd_resync(self): + """Manually trigger a resync.""" + if isinstance(self.ctx, UndertaleContext): + self.output(f"Syncing items.") + self.ctx.syncing = True + + def _cmd_patch(self): + """Patch the game.""" + if isinstance(self.ctx, UndertaleContext): + os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + self.ctx.patch_game() + self.output("Patched.") + + @mark_raw + def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): + """Patch the game automatically.""" + if isinstance(self.ctx, UndertaleContext): + os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + tempInstall = steaminstall + if not os.path.isfile(os.path.join(tempInstall, "data.win")): + tempInstall = None + if tempInstall is None: + tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" + elif not os.path.exists(tempInstall): + tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")): + self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder." + " command. \"/auto_patch (Steam directory)\".") + else: + for file_name in os.listdir(tempInstall): + if file_name != "steam_api.dll": + shutil.copy(tempInstall+"\\"+file_name, + os.getcwd() + "\\Undertale\\" + file_name) + self.ctx.patch_game() + self.output("Patching successful!") + + def _cmd_online(self): + """Makes you no longer able to see other Undertale players.""" + if isinstance(self.ctx, UndertaleContext): + self.ctx.update_online_mode(not ("Online" in self.ctx.tags)) + if "Online" in self.ctx.tags: + self.output(f"Now online.") + else: + self.output(f"Now offline.") + + def _cmd_deathlink(self): + """Toggles deathlink""" + if isinstance(self.ctx, UndertaleContext): + self.ctx.deathlink_status = not self.ctx.deathlink_status + if self.ctx.deathlink_status: + self.output(f"Deathlink enabled.") + else: + self.output(f"Deathlink disabled.") + + +class UndertaleContext(CommonContext): + tags = {"AP", "Online"} + game = "Undertale" + command_processor = UndertaleCommandProcessor + items_handling = 0b111 + route = None + pieces_needed = None + completed_routes = None + completed_count = 0 + save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.pieces_needed = 0 + self.game = "Undertale" + self.got_deathlink = False + self.syncing = False + self.deathlink_status = False + self.tem_armor = False + self.completed_count = 0 + self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} + + def patch_game(self): + with open(os.getcwd() + "/Undertale/data.win", "rb") as f: + patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) + with open(os.getcwd() + "/Undertale/data.win", "wb") as f: + f.write(patchedFile) + os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True) + with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" + + "Which Character.txt"), "w") as f: + f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only " + "line other than this one.\n", "frisk"]) + f.close() + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super().server_auth(password_requested) + await self.get_username() + await self.send_connect() + + def clear_undertale_files(self): + path = self.save_game_folder + self.finished_game = False + for root, dirs, files in os.walk(path): + for file in files: + if "check.spot" == file or "scout" == file: + os.remove(os.path.join(root, file)) + elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad", + ".youDied", ".LV", ".mine", ".flag", ".hint")): + os.remove(os.path.join(root, file)) + + async def connect(self, address: typing.Optional[str] = None): + self.clear_undertale_files() + await super().connect(address) + + async def disconnect(self, allow_autoreconnect: bool = False): + self.clear_undertale_files() + await super().disconnect(allow_autoreconnect) + + async def connection_closed(self): + self.clear_undertale_files() + await super().connection_closed() + + async def shutdown(self): + self.clear_undertale_files() + await super().shutdown() + + def update_online_mode(self, online): + old_tags = self.tags.copy() + if online: + self.tags.add("Online") + else: + self.tags -= {"Online"} + if old_tags != self.tags and self.server and not self.server.socket.closed: + async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])) + + def on_package(self, cmd: str, args: dict): + if cmd == "Connected": + self.game = self.slot_info[self.slot].game + async_start(process_undertale_cmd(self, cmd, args)) + + def run_gui(self): + from kvui import GameManager + + class UTManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago Undertale Client" + + self.ui = UTManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def on_deathlink(self, data: typing.Dict[str, typing.Any]): + self.got_deathlink = True + super().on_deathlink(data) + + +def to_room_name(place_name: str): + if place_name == "Old Home Exit": + return "room_ruinsexit" + elif place_name == "Snowdin Forest": + return "room_tundra1" + elif place_name == "Snowdin Town Exit": + return "room_fogroom" + elif place_name == "Waterfall": + return "room_water1" + elif place_name == "Waterfall Exit": + return "room_fire2" + elif place_name == "Hotland": + return "room_fire_prelab" + elif place_name == "Hotland Exit": + return "room_fire_precore" + elif place_name == "Core": + return "room_fire_core1" + + +async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): + if cmd == "Connected": + if not os.path.exists(ctx.save_game_folder): + os.mkdir(ctx.save_game_folder) + ctx.route = args["slot_data"]["route"] + ctx.pieces_needed = args["slot_data"]["key_pieces"] + ctx.tem_armor = args["slot_data"]["temy_armor_include"] + + await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral", + str(ctx.slot)+" RoutesDone pacifist", + str(ctx.slot)+" RoutesDone genocide"]}]) + await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral", + str(ctx.slot)+" RoutesDone pacifist", + str(ctx.slot)+" RoutesDone genocide"]}]) + if args["slot_data"]["only_flakes"]: + with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f: + f.close() + if not args["slot_data"]["key_hunt"]: + ctx.pieces_needed = 0 + if args["slot_data"]["rando_love"]: + filename = f"LOVErando.LV" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + if args["slot_data"]["rando_stats"]: + filename = f"STATrando.LV" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + filename = f"{ctx.route}.route" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + filename = f"check.spot" + with open(os.path.join(ctx.save_game_folder, filename), "a") as f: + for ss in ctx.checked_locations: + f.write(str(ss-12000)+"\n") + f.close() + elif cmd == "LocationInfo": + for l in args["locations"]: + locationid = l.location + filename = f"{str(locationid-12000)}.hint" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + toDraw = "" + for i in range(20): + if i < len(str(ctx.item_names[l.item])): + toDraw += str(ctx.item_names[l.item])[i] + else: + break + f.write(toDraw) + f.close() + elif cmd == "Retrieved": + if str(ctx.slot)+" RoutesDone neutral" in args["keys"]: + if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None: + ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"] + if str(ctx.slot)+" RoutesDone genocide" in args["keys"]: + if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None: + ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"] + if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]: + if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None: + ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"] + elif cmd == "SetReply": + if args["value"] is not None: + if str(ctx.slot)+" RoutesDone pacifist" == args["key"]: + ctx.completed_routes["pacifist"] = args["value"] + elif str(ctx.slot)+" RoutesDone genocide" == args["key"]: + ctx.completed_routes["genocide"] = args["value"] + elif str(ctx.slot)+" RoutesDone neutral" == args["key"]: + ctx.completed_routes["neutral"] = args["value"] + elif cmd == "ReceivedItems": + start_index = args["index"] + + if start_index == 0: + ctx.items_received = [] + elif start_index != len(ctx.items_received): + sync_msg = [{"cmd": "Sync"}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", + "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + if start_index == len(ctx.items_received): + counter = -1 + placedWeapon = 0 + placedArmor = 0 + for item in args["items"]: + id = NetworkItem(*item).location + while NetworkItem(*item).location < 0 and \ + counter <= id: + id -= 1 + if NetworkItem(*item).location < 0: + counter -= 1 + filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + if NetworkItem(*item).item == 77701: + if placedWeapon == 0: + f.write(str(77013-11000)) + elif placedWeapon == 1: + f.write(str(77014-11000)) + elif placedWeapon == 2: + f.write(str(77025-11000)) + elif placedWeapon == 3: + f.write(str(77045-11000)) + elif placedWeapon == 4: + f.write(str(77049-11000)) + elif placedWeapon == 5: + f.write(str(77047-11000)) + elif placedWeapon == 6: + if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes": + f.write(str(77052-11000)) + else: + f.write(str(77051-11000)) + else: + f.write(str(77003-11000)) + placedWeapon += 1 + elif NetworkItem(*item).item == 77702: + if placedArmor == 0: + f.write(str(77012-11000)) + elif placedArmor == 1: + f.write(str(77015-11000)) + elif placedArmor == 2: + f.write(str(77024-11000)) + elif placedArmor == 3: + f.write(str(77044-11000)) + elif placedArmor == 4: + f.write(str(77048-11000)) + elif placedArmor == 5: + if str(ctx.route) == "genocide": + f.write(str(77053-11000)) + else: + f.write(str(77046-11000)) + elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor): + if str(ctx.route) == "all_routes": + f.write(str(77053-11000)) + elif str(ctx.route) == "genocide": + f.write(str(77064-11000)) + else: + f.write(str(77050-11000)) + elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide": + f.write(str(77064-11000)) + else: + f.write(str(77004-11000)) + placedArmor += 1 + else: + f.write(str(NetworkItem(*item).item-11000)) + f.close() + ctx.items_received.append(NetworkItem(*item)) + if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0: + filename = f"{str(-99999)}PLR{str(0)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(77787 - 11000)) + f.close() + filename = f"{str(-99998)}PLR{str(0)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(77789 - 11000)) + f.close() + ctx.watcher_event.set() + + elif cmd == "RoomUpdate": + if "checked_locations" in args: + filename = f"check.spot" + with open(os.path.join(ctx.save_game_folder, filename), "a") as f: + for ss in ctx.checked_locations: + f.write(str(ss-12000)+"\n") + f.close() + + elif cmd == "Bounced": + tags = args.get("tags", []) + if "Online" in tags: + data = args.get("worlds/undertale/data", {}) + if data["player"] != ctx.slot and data["player"] is not None: + filename = f"FRISK" + str(data["player"]) + ".playerspot" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str( + data["spr"]) + str(data["frm"])) + f.close() + + +async def multi_watcher(ctx: UndertaleContext): + while not ctx.exit_event.is_set(): + path = ctx.save_game_folder + for root, dirs, files in os.walk(path): + for file in files: + if "spots.mine" in file and "Online" in ctx.tags: + with open(root + "/" + file, "r") as mine: + this_x = mine.readline() + this_y = mine.readline() + this_room = mine.readline() + this_sprite = mine.readline() + this_frame = mine.readline() + mine.close() + message = [{"cmd": "Bounce", "tags": ["Online"], + "data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room, + "spr": this_sprite, "frm": this_frame}}] + await ctx.send_msgs(message) + + await asyncio.sleep(0.1) + + +async def game_watcher(ctx: UndertaleContext): + while not ctx.exit_event.is_set(): + await ctx.update_death_link(ctx.deathlink_status) + path = ctx.save_game_folder + if ctx.syncing: + for root, dirs, files in os.walk(path): + for file in files: + if ".item" in file: + os.remove(root+"/"+file) + sync_msg = [{"cmd": "Sync"}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + ctx.syncing = False + if ctx.got_deathlink: + ctx.got_deathlink = False + with open(os.path.join(ctx.save_game_folder, "/WelcomeToTheDead.youDied"), "w") as f: + f.close() + sending = [] + victory = False + found_routes = 0 + for root, dirs, files in os.walk(path): + for file in files: + if "DontBeMad.mad" in file and "DeathLink" in ctx.tags: + os.remove(root+"/"+file) + await ctx.send_death() + if "scout" == file: + sending = [] + with open(root+"/"+file, "r") as f: + lines = f.readlines() + for l in lines: + if ctx.server_locations.__contains__(int(l)+12000): + sending = sending + [int(l)+12000] + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending, + "create_as_hint": int(2)}]) + os.remove(root+"/"+file) + if "check.spot" in file: + sending = [] + with open(root+"/"+file, "r") as f: + lines = f.readlines() + for l in lines: + sending = sending+[(int(l))+12000] + message = [{"cmd": "LocationChecks", "locations": sending}] + await ctx.send_msgs(message) + if "victory" in file and str(ctx.route) in file: + victory = True + if ".playerspot" in file and "Online" not in ctx.tags: + os.remove(root+"/"+file) + if "victory" in file: + if str(ctx.route) == "all_routes": + if "neutral" in file and ctx.completed_routes["neutral"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + elif "genocide" in file and ctx.completed_routes["genocide"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + if str(ctx.route) == "all_routes": + found_routes += ctx.completed_routes["neutral"] + found_routes += ctx.completed_routes["pacifist"] + found_routes += ctx.completed_routes["genocide"] + if str(ctx.route) == "all_routes" and found_routes >= 3: + victory = True + ctx.locations_checked = sending + if (not ctx.finished_game) and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + await asyncio.sleep(0.1) + + +def main(): + Utils.init_logging("UndertaleClient", exception_logger="Client") + + async def _main(): + ctx = UndertaleContext(None, None) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + asyncio.create_task( + game_watcher(ctx), name="UndertaleProgressionWatcher") + + asyncio.create_task( + multi_watcher(ctx), name="UndertaleMultiplayerWatcher") + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + await ctx.exit_event.wait() + await ctx.shutdown() + + import colorama + + colorama.init() + + asyncio.run(_main()) + colorama.deinit() + + +if __name__ == "__main__": + parser = get_base_parser(description="Undertale Client, for text interfacing.") + args = parser.parse_args() + main() diff --git a/inno_setup.iss b/inno_setup.iss index c117bdd60ad5..bd4d10eae661 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -88,6 +88,7 @@ Name: "client/wargroove"; Description: "Wargroove"; Types: full playing Name: "client/zl"; Description: "Zillion"; Types: full playing Name: "client/tloz"; Description: "The Legend of Zelda"; Types: full playing Name: "client/advn"; Description: "Adventure"; Types: full playing +Name: "client/ut"; Description: "Undertale"; Types: full playing Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing [Dirs] @@ -131,6 +132,7 @@ Source: "{#source_path}\ArchipelagoZelda1Client.exe"; DestDir: "{app}"; Flags: i Source: "{#source_path}\ArchipelagoWargrooveClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/wargroove Source: "{#source_path}\ArchipelagoKH2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/kh2 Source: "{#source_path}\ArchipelagoAdventureClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/advn +Source: "{#source_path}\ArchipelagoUndertaleClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ut Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall [Icons] @@ -150,6 +152,7 @@ Name: "{group}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\Archip Name: "{group}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Components: client/kh2 Name: "{group}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Components: client/advn Name: "{group}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Components: client/wargroove +Name: "{group}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Components: client/ut Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server @@ -166,6 +169,7 @@ Name: "{commondesktop}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app Name: "{commondesktop}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Tasks: desktopicon; Components: client/wargroove Name: "{commondesktop}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Tasks: desktopicon; Components: client/kh2 Name: "{commondesktop}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Tasks: desktopicon; Components: client/advn +Name: "{commondesktop}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Tasks: desktopicon; Components: client/ut [Run] diff --git a/worlds/undertale/Items.py b/worlds/undertale/Items.py new file mode 100644 index 000000000000..50811bd1dec6 --- /dev/null +++ b/worlds/undertale/Items.py @@ -0,0 +1,241 @@ +from BaseClasses import Item, ItemClassification +import typing + + +class ItemData(typing.NamedTuple): + code: typing.Optional[int] + classification: any + + +class UndertaleItem(Item): + game: str = "Undertale" + + +item_table = { + "Progressive Plot": ItemData(77700, ItemClassification.progression), + "Progressive Weapons": ItemData(77701, ItemClassification.useful), + "Progressive Armor": ItemData(77702, ItemClassification.useful), + "Monster Candy": ItemData(77001, ItemClassification.filler), + "Croquet Roll": ItemData(77002, ItemClassification.filler), + "Stick": ItemData(77003, ItemClassification.useful), + "Bandage": ItemData(77004, ItemClassification.useful), + "Rock Candy": ItemData(77005, ItemClassification.filler), + "Pumpkin Rings": ItemData(77006, ItemClassification.filler), + "Spider Donut": ItemData(77007, ItemClassification.filler), + "Stoic Onion": ItemData(77008, ItemClassification.filler), + "Ghost Fruit": ItemData(77009, ItemClassification.filler), + "Spider Cider": ItemData(77010, ItemClassification.filler), + "Butterscotch Pie": ItemData(77011, ItemClassification.useful), + "Faded Ribbon": ItemData(77012, ItemClassification.useful), + "Toy Knife": ItemData(77013, ItemClassification.useful), + "Tough Glove": ItemData(77014, ItemClassification.useful), + "Manly Bandanna": ItemData(77015, ItemClassification.useful), + "Snowman Piece": ItemData(77016, ItemClassification.useful), + "Nice Cream": ItemData(77017, ItemClassification.filler), + "Puppydough Icecream": ItemData(77018, ItemClassification.filler), + "Bisicle": ItemData(77019, ItemClassification.filler), + "Unisicle": ItemData(77020, ItemClassification.filler), + "Cinnamon Bun": ItemData(77021, ItemClassification.filler), + "Temmie Flakes": ItemData(77022, ItemClassification.filler), + "Abandoned Quiche": ItemData(77023, ItemClassification.filler), + "Old Tutu": ItemData(77024, ItemClassification.useful), + "Ballet Shoes": ItemData(77025, ItemClassification.useful), + "Punch Card": ItemData(77026, ItemClassification.progression), + "Annoying Dog": ItemData(77027, ItemClassification.filler), + "Dog Salad": ItemData(77028, ItemClassification.filler), + "Dog Residue": ItemData(77029, ItemClassification.filler), + "Astronaut Food": ItemData(77035, ItemClassification.filler), + "Instant Noodles": ItemData(77036, ItemClassification.useful), + "Crab Apple": ItemData(77037, ItemClassification.filler), + "Hot Dog...?": ItemData(77038, ItemClassification.progression), + "Hot Cat": ItemData(77039, ItemClassification.filler), + "Glamburger": ItemData(77040, ItemClassification.filler), + "Sea Tea": ItemData(77041, ItemClassification.filler), + "Starfait": ItemData(77042, ItemClassification.filler), + "Legendary Hero": ItemData(77043, ItemClassification.filler), + "Cloudy Glasses": ItemData(77044, ItemClassification.useful), + "Torn Notebook": ItemData(77045, ItemClassification.useful), + "Stained Apron": ItemData(77046, ItemClassification.useful), + "Burnt Pan": ItemData(77047, ItemClassification.useful), + "Cowboy Hat": ItemData(77048, ItemClassification.useful), + "Empty Gun": ItemData(77049, ItemClassification.useful), + "Heart Locket": ItemData(77050, ItemClassification.useful), + "Worn Dagger": ItemData(77051, ItemClassification.useful), + "Real Knife": ItemData(77052, ItemClassification.useful), + "The Locket": ItemData(77053, ItemClassification.useful), + "Bad Memory": ItemData(77054, ItemClassification.filler), + "Dream": ItemData(77055, ItemClassification.filler), + "Undyne's Letter": ItemData(77056, ItemClassification.filler), + "Undyne Letter EX": ItemData(77057, ItemClassification.progression), + "Popato Chisps": ItemData(77058, ItemClassification.filler), + "Junk Food": ItemData(77059, ItemClassification.filler), + "Mystery Key": ItemData(77060, ItemClassification.filler), + "Face Steak": ItemData(77061, ItemClassification.filler), + "Hush Puppy": ItemData(77062, ItemClassification.filler), + "Snail Pie": ItemData(77063, ItemClassification.filler), + "temy armor": ItemData(77064, ItemClassification.useful), + "Complete Skeleton": ItemData(77779, ItemClassification.progression), + "Fish": ItemData(77780, ItemClassification.progression), + "DT Extractor": ItemData(77782, ItemClassification.progression), + "Mettaton Plush": ItemData(77786, ItemClassification.progression), + "Left Home Key": ItemData(77787, ItemClassification.progression), + "LOVE": ItemData(77788, ItemClassification.useful), + "Right Home Key": ItemData(77789, ItemClassification.progression), + "Key Piece": ItemData(77000, ItemClassification.progression), + "100G": ItemData(77999, ItemClassification.useful), + "500G": ItemData(77998, ItemClassification.useful), + "1000G": ItemData(77997, ItemClassification.progression), + "ATK Up": ItemData(77065, ItemClassification.useful), + "DEF Up": ItemData(77066, ItemClassification.useful), + "HP Up": ItemData(77067, ItemClassification.useful), + "FIGHT": ItemData(77077, ItemClassification.progression), + "ACT": ItemData(77078, ItemClassification.progression), + "ITEM": ItemData(77079, ItemClassification.progression), + "MERCY": ItemData(77080, ItemClassification.progression), + "Ruins Key": ItemData(77081, ItemClassification.progression), + "Snowdin Key": ItemData(77082, ItemClassification.progression), + "Waterfall Key": ItemData(77083, ItemClassification.progression), + "Hotland Key": ItemData(77084, ItemClassification.progression), + "Core Key": ItemData(77085, ItemClassification.progression), + "Undyne Date": ItemData(None, ItemClassification.progression), + "Alphys Date": ItemData(None, ItemClassification.progression), + "Papyrus Date": ItemData(None, ItemClassification.progression), +} + +non_key_items = { + "Butterscotch Pie": 1, + "500G": 2, + "1000G": 2, + "Face Steak": 1, + "Snowman Piece": 1, + "Instant Noodles": 1, + "Astronaut Food": 2, + "Hot Cat": 1, + "Abandoned Quiche": 1, + "Spider Donut": 1, + "Spider Cider": 1, + "Hush Puppy": 1, +} + +required_armor = { + "Cloudy Glasses": 1, + "Manly Bandanna": 1, + "Old Tutu": 1, + "Stained Apron": 1, + "Heart Locket": 1, + "Faded Ribbon": 1, + "Cowboy Hat": 1, +} + +required_weapons = { + "Torn Notebook": 1, + "Tough Glove": 1, + "Ballet Shoes": 1, + "Burnt Pan": 1, + "Worn Dagger": 1, + "Toy Knife": 1, + "Empty Gun": 1, +} + +plot_items = { + "Complete Skeleton": 1, + "Fish": 1, + "Mettaton Plush": 1, + "DT Extractor": 1, +} + +key_items = { + "Complete Skeleton": 1, + "Fish": 1, + "DT Extractor": 1, + "Mettaton Plush": 1, + "Punch Card": 3, + "Hot Dog...?": 1, + "ATK Up": 19, + "DEF Up": 4, + "HP Up": 19, + "LOVE": 19, + "Ruins Key": 1, + "Snowdin Key": 1, + "Waterfall Key": 1, + "Hotland Key": 1, + "Core Key": 1, +} + +junk_weights_all = { + "Bisicle": 12, + "Legendary Hero": 8, + "Glamburger": 10, + "Crab Apple": 12, + "Sea Tea": 12, + "Nice Cream": 10, + "Spider Donut": 10, + "Popato Chisps": 12, + "Junk Food": 12, + "Temmie Flakes": 10, + "Spider Cider": 8, + "Hot Dog...?": 10, + "Cinnamon Bun": 10, + "Starfait": 12, + "Punch Card": 8, + "Monster Candy": 6, + "100G": 6, + "500G": 3, +} + +junk_weights_neutral = { + "Bisicle": 12, + "Legendary Hero": 8, + "Glamburger": 10, + "Crab Apple": 12, + "Sea Tea": 12, + "Nice Cream": 10, + "Spider Donut": 10, + "Junk Food": 12, + "Temmie Flakes": 10, + "Spider Cider": 8, + "Cinnamon Bun": 10, + "Starfait": 12, + "Punch Card": 8, + "Monster Candy": 6, + "100G": 6, + "500G": 3, +} + +junk_weights_pacifist = { + "Bisicle": 12, + "Legendary Hero": 8, + "Glamburger": 10, + "Crab Apple": 12, + "Sea Tea": 12, + "Nice Cream": 10, + "Spider Donut": 10, + "Popato Chisps": 12, + "Junk Food": 12, + "Temmie Flakes": 10, + "Spider Cider": 8, + "Hot Dog...?": 10, + "Cinnamon Bun": 10, + "Starfait": 12, + "Punch Card": 8, + "Monster Candy": 6, + "100G": 6, + "500G": 3, +} + +junk_weights_genocide = { + "Bisicle": 12, + "Legendary Hero": 8, + "Glamburger": 10, + "Crab Apple": 12, + "Sea Tea": 12, + "Spider Donut": 10, + "Junk Food": 12, + "Temmie Flakes": 10, + "Spider Cider": 8, + "Cinnamon Bun": 10, + "Starfait": 12, + "Monster Candy": 6, + "100G": 6, + "500G": 3, +} diff --git a/worlds/undertale/Locations.py b/worlds/undertale/Locations.py new file mode 100644 index 000000000000..2f7de44512fa --- /dev/null +++ b/worlds/undertale/Locations.py @@ -0,0 +1,376 @@ +from BaseClasses import Location +import typing + + +class AdvData(typing.NamedTuple): + id: typing.Optional[int] + region: str + + +class UndertaleAdvancement(Location): + game: str = "Undertale" + + def __init__(self, player: int, name: str, address: typing.Optional[int], parent): + super().__init__(player, name, address, parent) + self.event = not address + + +advancement_table = { + "Snowman": AdvData(79100, "Snowdin Forest"), + "Snowman 2": AdvData(79101, "Snowdin Forest"), + "Snowman 3": AdvData(79102, "Snowdin Forest"), + "Nicecream Snowdin": AdvData(79001, "Snowdin Forest"), + "Nicecream Waterfall": AdvData(79002, "Waterfall"), + "Nicecream Punch Card": AdvData(79003, "Waterfall"), + "Quiche Bench": AdvData(79004, "Waterfall"), + "Tutu Hidden": AdvData(79005, "Waterfall"), + "Card Reward": AdvData(79006, "Waterfall"), + "Grass Shoes": AdvData(79007, "Waterfall"), + "Noodles Fridge": AdvData(79008, "Hotland"), + "Pan Hidden": AdvData(79009, "Hotland"), + "Apron Hidden": AdvData(79010, "Hotland"), + "Trash Burger": AdvData(79011, "Core"), + "Present Knife": AdvData(79012, "New Home"), + "Present Locket": AdvData(79013, "New Home"), + "Candy 1": AdvData(79014, "Ruins"), + "Candy 2": AdvData(79015, "Ruins"), + "Candy 3": AdvData(79016, "Ruins"), + "Candy 4": AdvData(79017, "Ruins"), + "Donut Sale": AdvData(79018, "Ruins"), + "Cider Sale": AdvData(79019, "Ruins"), + "Ribbon Cracks": AdvData(79020, "Ruins"), + "Toy Knife Edge": AdvData(79021, "Ruins"), + "B.Scotch Pie Given": AdvData(79022, "Ruins"), + "Astro 1": AdvData(79023, "Waterfall"), + "Astro 2": AdvData(79024, "Waterfall"), + "Dog Sale 1": AdvData(79026, "Hotland"), + "Cat Sale": AdvData(79027, "Hotland"), + "Dog Sale 2": AdvData(79028, "Hotland"), + "Dog Sale 3": AdvData(79029, "Hotland"), + "Dog Sale 4": AdvData(79030, "Hotland"), + "Chisps Machine": AdvData(79031, "True Lab"), + "Hush Trade": AdvData(79032, "Hotland"), + "Letter Quest": AdvData(79033, "Snowdin Town"), + "Bunny 1": AdvData(79034, "Snowdin Town"), + "Bunny 2": AdvData(79035, "Snowdin Town"), + "Bunny 3": AdvData(79036, "Snowdin Town"), + "Bunny 4": AdvData(79037, "Snowdin Town"), + "Gerson 1": AdvData(79038, "Waterfall"), + "Gerson 2": AdvData(79039, "Waterfall"), + "Gerson 3": AdvData(79040, "Waterfall"), + "Gerson 4": AdvData(79041, "Waterfall"), + "Bratty Catty 1": AdvData(79042, "Hotland"), + "Bratty Catty 2": AdvData(79043, "Hotland"), + "Bratty Catty 3": AdvData(79044, "Hotland"), + "Bratty Catty 4": AdvData(79045, "Hotland"), + "Burgerpants 1": AdvData(79046, "Hotland"), + "Burgerpants 2": AdvData(79047, "Hotland"), + "Burgerpants 3": AdvData(79048, "Hotland"), + "Burgerpants 4": AdvData(79049, "Hotland"), + "TemmieShop 1": AdvData(79050, "Waterfall"), + "TemmieShop 2": AdvData(79051, "Waterfall"), + "TemmieShop 3": AdvData(79052, "Waterfall"), + "TemmieShop 4": AdvData(79053, "Waterfall"), + "Papyrus Plot": AdvData(79056, "Snowdin Town"), + "Undyne Plot": AdvData(79057, "Waterfall"), + "Mettaton Plot": AdvData(79062, "Core"), + "True Lab Plot": AdvData(79063, "Hotland"), + "Left New Home Key": AdvData(79064, "New Home"), + "Right New Home Key": AdvData(79065, "New Home"), + "LOVE 2": AdvData(79902, "???"), + "LOVE 3": AdvData(79903, "???"), + "LOVE 4": AdvData(79904, "???"), + "LOVE 5": AdvData(79905, "???"), + "LOVE 6": AdvData(79906, "???"), + "LOVE 7": AdvData(79907, "???"), + "LOVE 8": AdvData(79908, "???"), + "LOVE 9": AdvData(79909, "???"), + "LOVE 10": AdvData(79910, "???"), + "LOVE 11": AdvData(79911, "???"), + "LOVE 12": AdvData(79912, "???"), + "LOVE 13": AdvData(79913, "???"), + "LOVE 14": AdvData(79914, "???"), + "LOVE 15": AdvData(79915, "???"), + "LOVE 16": AdvData(79916, "???"), + "LOVE 17": AdvData(79917, "???"), + "LOVE 18": AdvData(79918, "???"), + "LOVE 19": AdvData(79919, "???"), + "LOVE 20": AdvData(79920, "???"), + "ATK 2": AdvData(79800, "???"), + "ATK 3": AdvData(79801, "???"), + "ATK 4": AdvData(79802, "???"), + "ATK 5": AdvData(79803, "???"), + "ATK 6": AdvData(79804, "???"), + "ATK 7": AdvData(79805, "???"), + "ATK 8": AdvData(79806, "???"), + "ATK 9": AdvData(79807, "???"), + "ATK 10": AdvData(79808, "???"), + "ATK 11": AdvData(79809, "???"), + "ATK 12": AdvData(79810, "???"), + "ATK 13": AdvData(79811, "???"), + "ATK 14": AdvData(79812, "???"), + "ATK 15": AdvData(79813, "???"), + "ATK 16": AdvData(79814, "???"), + "ATK 17": AdvData(79815, "???"), + "ATK 18": AdvData(79816, "???"), + "ATK 19": AdvData(79817, "???"), + "ATK 20": AdvData(79818, "???"), + "DEF 5": AdvData(79700, "???"), + "DEF 9": AdvData(79701, "???"), + "DEF 13": AdvData(79702, "???"), + "DEF 17": AdvData(79703, "???"), + "HP 2": AdvData(79600, "???"), + "HP 3": AdvData(79601, "???"), + "HP 4": AdvData(79602, "???"), + "HP 5": AdvData(79603, "???"), + "HP 6": AdvData(79604, "???"), + "HP 7": AdvData(79605, "???"), + "HP 8": AdvData(79606, "???"), + "HP 9": AdvData(79607, "???"), + "HP 10": AdvData(79608, "???"), + "HP 11": AdvData(79609, "???"), + "HP 12": AdvData(79610, "???"), + "HP 13": AdvData(79611, "???"), + "HP 14": AdvData(79612, "???"), + "HP 15": AdvData(79613, "???"), + "HP 16": AdvData(79614, "???"), + "HP 17": AdvData(79615, "???"), + "HP 18": AdvData(79616, "???"), + "HP 19": AdvData(79617, "???"), + "HP 20": AdvData(79618, "???"), + "Undyne Date": AdvData(None, "Undyne\"s Home"), + "Alphys Date": AdvData(None, "Hotland"), + "Papyrus Date": AdvData(None, "Papyrus\" Home"), +} + +exclusion_table = { + "pacifist": { + "LOVE 2", + "LOVE 3", + "LOVE 4", + "LOVE 5", + "LOVE 6", + "LOVE 7", + "LOVE 8", + "LOVE 9", + "LOVE 10", + "LOVE 11", + "LOVE 12", + "LOVE 13", + "LOVE 14", + "LOVE 15", + "LOVE 16", + "LOVE 17", + "LOVE 18", + "LOVE 19", + "LOVE 20", + "ATK 2", + "ATK 3", + "ATK 4", + "ATK 5", + "ATK 6", + "ATK 7", + "ATK 8", + "ATK 9", + "ATK 10", + "ATK 11", + "ATK 12", + "ATK 13", + "ATK 14", + "ATK 15", + "ATK 16", + "ATK 17", + "ATK 18", + "ATK 19", + "ATK 20", + "DEF 5", + "DEF 9", + "DEF 13", + "DEF 17", + "HP 2", + "HP 3", + "HP 4", + "HP 5", + "HP 6", + "HP 7", + "HP 8", + "HP 9", + "HP 10", + "HP 11", + "HP 12", + "HP 13", + "HP 14", + "HP 15", + "HP 16", + "HP 17", + "HP 18", + "HP 19", + "HP 20", + "Snowman 2", + "Snowman 3", + }, + "neutral": { + "Letter Quest", + "Dog Sale 1", + "Cat Sale", + "Dog Sale 2", + "Dog Sale 3", + "Dog Sale 4", + "Chisps Machine", + "Hush Trade", + "LOVE 2", + "LOVE 3", + "LOVE 4", + "LOVE 5", + "LOVE 6", + "LOVE 7", + "LOVE 8", + "LOVE 9", + "LOVE 10", + "LOVE 11", + "LOVE 12", + "LOVE 13", + "LOVE 14", + "LOVE 15", + "LOVE 16", + "LOVE 17", + "LOVE 18", + "LOVE 19", + "LOVE 20", + "Papyrus Plot", + "Undyne Plot", + "True Lab Plot", + "ATK 2", + "ATK 3", + "ATK 4", + "ATK 5", + "ATK 6", + "ATK 7", + "ATK 8", + "ATK 9", + "ATK 10", + "ATK 11", + "ATK 12", + "ATK 13", + "ATK 14", + "ATK 15", + "ATK 16", + "ATK 17", + "ATK 18", + "ATK 19", + "ATK 20", + "DEF 5", + "DEF 9", + "DEF 13", + "DEF 17", + "HP 2", + "HP 3", + "HP 4", + "HP 5", + "HP 6", + "HP 7", + "HP 8", + "HP 9", + "HP 10", + "HP 11", + "HP 12", + "HP 13", + "HP 14", + "HP 15", + "HP 16", + "HP 17", + "HP 18", + "HP 19", + "HP 20", + "Snowman 2", + "Snowman 3", + }, + "genocide": { + "Letter Quest", + "Dog Sale 1", + "Cat Sale", + "Dog Sale 2", + "Dog Sale 3", + "Dog Sale 4", + "Chisps Machine", + "Nicecream Snowdin", + "Nicecream Waterfall", + "Nicecream Punch Card", + "Card Reward", + "Apron Hidden", + "Hush Trade", + "Papyrus Plot", + "Undyne Plot", + "True Lab Plot", + }, + "NoLove": { + "LOVE 2", + "LOVE 3", + "LOVE 4", + "LOVE 5", + "LOVE 6", + "LOVE 7", + "LOVE 8", + "LOVE 9", + "LOVE 10", + "LOVE 11", + "LOVE 12", + "LOVE 13", + "LOVE 14", + "LOVE 15", + "LOVE 16", + "LOVE 17", + "LOVE 18", + "LOVE 19", + "LOVE 20", + }, + "NoStats": { + "ATK 2", + "ATK 3", + "ATK 4", + "ATK 5", + "ATK 6", + "ATK 7", + "ATK 8", + "ATK 9", + "ATK 10", + "ATK 11", + "ATK 12", + "ATK 13", + "ATK 14", + "ATK 15", + "ATK 16", + "ATK 17", + "ATK 18", + "ATK 19", + "ATK 20", + "DEF 5", + "DEF 9", + "DEF 13", + "DEF 17", + "HP 2", + "HP 3", + "HP 4", + "HP 5", + "HP 6", + "HP 7", + "HP 8", + "HP 9", + "HP 10", + "HP 11", + "HP 12", + "HP 13", + "HP 14", + "HP 15", + "HP 16", + "HP 17", + "HP 18", + "HP 19", + "HP 20", + }, + "all_routes": { + } +} + +events_table = { +} diff --git a/worlds/undertale/Options.py b/worlds/undertale/Options.py new file mode 100644 index 000000000000..87eaa820d463 --- /dev/null +++ b/worlds/undertale/Options.py @@ -0,0 +1,90 @@ +import typing +from Options import Choice, Option, Toggle, Range + + +class RouteRequired(Choice): + """Main route of the game required to win.""" + display_name = "Required Route" + option_neutral = 0 + option_pacifist = 1 + option_genocide = 2 + option_all_routes = 3 + default = 0 + + +class IncludeTemy(Toggle): + """Adds Temmy Armor to the item pool.""" + display_name = "Include Temy Armor" + default = 1 + + +class KeyPieces(Range): + """How many Key Pieces are added to the pool, only matters with Key Piece Hunt enabled.""" + display_name = "Key Piece Amount" + default = 5 + range_start = 1 + range_end = 10 + + +class KeyHunt(Toggle): + """Adds Key Pieces to the item pool, you need all of them to enter the last corridor.""" + display_name = "Key Piece Hunt" + default = 0 + + +class ProgressiveArmor(Toggle): + """Makes the armor progressive.""" + display_name = "Progressive Armor" + default = 0 + + +class ProgressiveWeapons(Toggle): + """Makes the weapons progressive.""" + display_name = "Progressive Weapons" + default = 0 + + +class OnlyFlakes(Toggle): + """Replaces all non-required items, except equipment, with Temmie Flakes.""" + display_name = "Only Temmie Flakes" + default = 0 + + +class NoEquips(Toggle): + """Removes all equippable items.""" + display_name = "No Equippables" + default = 0 + + +class RandomizeLove(Toggle): + """Adds LOVE to the pool. GENOCIDE ONLY!""" + display_name = "Randomize LOVE" + default = 0 + + +class RandomizeStats(Toggle): + """Makes each stat increase from LV a separate item. GENOCIDE ONLY! + Warning: This tends to spam chat with sending out checks.""" + display_name = "Randomize Stats" + default = 0 + + +class RandoBattleOptions(Toggle): + """Turns the ITEM button in battle into an item you have to receive.""" + display_name = "Randomize Item Button" + default = 0 + + +undertale_options: typing.Dict[str, type(Option)] = { + "route_required": RouteRequired, + "key_hunt": KeyHunt, + "key_pieces": KeyPieces, + "rando_love": RandomizeLove, + "rando_stats": RandomizeStats, + "temy_include": IncludeTemy, + "no_equips": NoEquips, + "only_flakes": OnlyFlakes, + "prog_armor": ProgressiveArmor, + "prog_weapons": ProgressiveWeapons, + "rando_item_button": RandoBattleOptions, +} diff --git a/worlds/undertale/Regions.py b/worlds/undertale/Regions.py new file mode 100644 index 000000000000..ec13b249fa0e --- /dev/null +++ b/worlds/undertale/Regions.py @@ -0,0 +1,48 @@ +from BaseClasses import MultiWorld + + +def link_undertale_areas(world: MultiWorld, player: int): + for (exit, region) in mandatory_connections: + world.get_entrance(exit, player).connect(world.get_region(region, player)) + + +# (Region name, list of exits) +undertale_regions = [ + ("Menu", ["New Game", "??? Exit"]), + ("???", []), + ("Hub", ["Ruins Hub", "Snowdin Hub", "Waterfall Hub", "Hotland Hub", "Core Hub"]), + ("Ruins", ["Ruins Exit"]), + ("Old Home", []), + ("Snowdin Forest", ["Snowdin Forest Exit"]), + ("Snowdin Town", ["Papyrus\" Home Entrance"]), + ("Papyrus\" Home", []), + ("Waterfall", ["Undyne\"s Home Entrance"]), + ("Undyne\"s Home", []), + ("Hotland", ["Cooking Show Entrance", "Lab Elevator"]), + ("Cooking Show", ["News Show Entrance"]), + ("News Show", []), + ("True Lab", []), + ("Core", ["Core Exit"]), + ("New Home", ["New Home Exit"]), + ("Barrier", []), +] + +# (Entrance, region pointed to) +mandatory_connections = [ + ("??? Exit", "???"), + ("New Game", "Hub"), + ("Ruins Hub", "Ruins"), + ("Ruins Exit", "Old Home"), + ("Snowdin Forest Exit", "Snowdin Town"), + ("Papyrus\" Home Entrance", "Papyrus\" Home"), + ("Undyne\"s Home Entrance", "Undyne\"s Home"), + ("Cooking Show Entrance", "Cooking Show"), + ("News Show Entrance", "News Show"), + ("Lab Elevator", "True Lab"), + ("Core Exit", "New Home"), + ("New Home Exit", "Barrier"), + ("Snowdin Hub", "Snowdin Forest"), + ("Waterfall Hub", "Waterfall"), + ("Hotland Hub", "Hotland"), + ("Core Hub", "Core"), +] diff --git a/worlds/undertale/Rules.py b/worlds/undertale/Rules.py new file mode 100644 index 000000000000..4c8d4f4f75e1 --- /dev/null +++ b/worlds/undertale/Rules.py @@ -0,0 +1,360 @@ +from ..generic.Rules import set_rule, add_rule +from BaseClasses import MultiWorld, CollectionState + + +def _undertale_is_route(state: CollectionState, player: int, route: int): + if route == 3: + return state.multiworld.route_required[player].current_key == "all_routes" + if state.multiworld.route_required[player].current_key == "all_routes": + return True + if route == 0: + return state.multiworld.route_required[player].current_key == "neutral" + if route == 1: + return state.multiworld.route_required[player].current_key == "pacifist" + if route == 2: + return state.multiworld.route_required[player].current_key == "genocide" + return False + + +def _undertale_has_plot(state: CollectionState, player: int, item: str): + if item == "Complete Skeleton": + return state.has("Complete Skeleton", player) + elif item == "Fish": + return state.has("Fish", player) + elif item == "Mettaton Plush": + return state.has("Mettaton Plush", player) + elif item == "DT Extractor": + return state.has("DT Extractor", player) + + +def _undertale_can_level(state: CollectionState, exp: int, lvl: int): + if exp >= 10 and lvl == 1: + return True + elif exp >= 30 and lvl == 2: + return True + elif exp >= 70 and lvl == 3: + return True + elif exp >= 120 and lvl == 4: + return True + elif exp >= 200 and lvl == 5: + return True + elif exp >= 300 and lvl == 6: + return True + elif exp >= 500 and lvl == 7: + return True + elif exp >= 800 and lvl == 8: + return True + elif exp >= 1200 and lvl == 9: + return True + elif exp >= 1700 and lvl == 10: + return True + elif exp >= 2500 and lvl == 11: + return True + elif exp >= 3500 and lvl == 12: + return True + elif exp >= 5000 and lvl == 13: + return True + elif exp >= 7000 and lvl == 14: + return True + elif exp >= 10000 and lvl == 15: + return True + elif exp >= 15000 and lvl == 16: + return True + elif exp >= 25000 and lvl == 17: + return True + elif exp >= 50000 and lvl == 18: + return True + elif exp >= 99999 and lvl == 19: + return True + return False + + +# Sets rules on entrances and advancements that are always applied +def set_rules(multiworld: MultiWorld, player: int): + set_rule(multiworld.get_entrance("Ruins Hub", player), lambda state: state.has("Ruins Key", player)) + set_rule(multiworld.get_entrance("Snowdin Hub", player), lambda state: state.has("Snowdin Key", player)) + set_rule(multiworld.get_entrance("Waterfall Hub", player), lambda state: state.has("Waterfall Key", player)) + set_rule(multiworld.get_entrance("Hotland Hub", player), lambda state: state.has("Hotland Key", player)) + set_rule(multiworld.get_entrance("Core Hub", player), lambda state: state.has("Core Key", player)) + if _undertale_is_route(multiworld.state, player, 1): + add_rule(multiworld.get_entrance("Snowdin Hub", player), lambda state: state.has("ACT", player) and state.has("MERCY", player)) + add_rule(multiworld.get_entrance("Waterfall Hub", player), lambda state: state.has("ACT", player) and state.has("MERCY", player)) + add_rule(multiworld.get_entrance("Hotland Hub", player), lambda state: state.has("ACT", player) and state.has("MERCY", player)) + add_rule(multiworld.get_entrance("Core Hub", player), lambda state: state.has("ACT", player) and state.has("MERCY", player)) + if _undertale_is_route(multiworld.state, player, 2) or _undertale_is_route(multiworld.state, player, 3): + add_rule(multiworld.get_entrance("Snowdin Hub", player), lambda state: state.has("FIGHT", player)) + add_rule(multiworld.get_entrance("Waterfall Hub", player), lambda state: state.has("FIGHT", player)) + add_rule(multiworld.get_entrance("Hotland Hub", player), lambda state: state.has("FIGHT", player)) + add_rule(multiworld.get_entrance("Core Hub", player), lambda state: state.has("FIGHT", player)) + if _undertale_is_route(multiworld.state, player, 0): + add_rule(multiworld.get_entrance("Snowdin Hub", player), lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + add_rule(multiworld.get_entrance("Waterfall Hub", player), lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + add_rule(multiworld.get_entrance("Hotland Hub", player), lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + add_rule(multiworld.get_entrance("Core Hub", player), lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + set_rule(multiworld.get_entrance("Core Exit", player), + lambda state: _undertale_has_plot(state, player, "Mettaton Plush")) + set_rule(multiworld.get_entrance("New Home Exit", player), + lambda state: (state.has("Left Home Key", player) and + state.has("Right Home Key", player)) or + state.has("Key Piece", player, state.multiworld.key_pieces[player])) + if _undertale_is_route(multiworld.state, player, 1): + set_rule(multiworld.get_entrance("Papyrus\" Home Entrance", player), + lambda state: _undertale_has_plot(state, player, "Complete Skeleton")) + set_rule(multiworld.get_entrance("Undyne\"s Home Entrance", player), + lambda state: _undertale_has_plot(state, player, "Fish") and state.has("Papyrus Date", player)) + set_rule(multiworld.get_entrance("Lab Elevator", player), + lambda state: state.has("Alphys Date", player) and _undertale_has_plot(state, player, "DT Extractor")) + set_rule(multiworld.get_location("Alphys Date", player), + lambda state: state.has("Undyne Letter EX", player) and state.has("Undyne Date", player)) + set_rule(multiworld.get_location("Papyrus Plot", player), + lambda state: state.can_reach("Snowdin Town", "Region", player)) + set_rule(multiworld.get_location("Undyne Plot", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("True Lab Plot", player), + lambda state: state.can_reach("New Home", "Region", player) + and state.can_reach("Letter Quest", "Location", player)) + set_rule(multiworld.get_location("Chisps Machine", player), + lambda state: state.can_reach("True Lab", "Region", player)) + set_rule(multiworld.get_location("Dog Sale 1", player), + lambda state: state.can_reach("Cooking Show", "Region", player)) + set_rule(multiworld.get_location("Cat Sale", player), + lambda state: state.can_reach("Cooking Show", "Region", player)) + set_rule(multiworld.get_location("Dog Sale 2", player), + lambda state: state.can_reach("Cooking Show", "Region", player)) + set_rule(multiworld.get_location("Dog Sale 3", player), + lambda state: state.can_reach("Cooking Show", "Region", player)) + set_rule(multiworld.get_location("Dog Sale 4", player), + lambda state: state.can_reach("Cooking Show", "Region", player)) + set_rule(multiworld.get_location("Hush Trade", player), + lambda state: state.can_reach("News Show", "Region", player) and state.has("Hot Dog...?", player, 1)) + set_rule(multiworld.get_location("Letter Quest", player), + lambda state: state.can_reach("New Home Exit", "Entrance", player)) + if (not _undertale_is_route(multiworld.state, player, 2)) or _undertale_is_route(multiworld.state, player, 3): + set_rule(multiworld.get_location("Nicecream Punch Card", player), + lambda state: state.has("Punch Card", player, 3) and state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Nicecream Snowdin", player), + lambda state: state.can_reach("Snowdin Town", "Region", player)) + set_rule(multiworld.get_location("Nicecream Waterfall", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Card Reward", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Apron Hidden", player), + lambda state: state.can_reach("Cooking Show", "Region", player)) + if _undertale_is_route(multiworld.state, player, 2) and \ + (multiworld.rando_love[player] or multiworld.rando_stats[player]): + maxlv = 1 + exp = 190 + curarea = "Old Home" + + while maxlv < 20: + maxlv += 1 + if multiworld.rando_love[player]: + set_rule(multiworld.get_location(("LOVE " + str(maxlv)), player), lambda state: False) + if multiworld.rando_stats[player]: + set_rule(multiworld.get_location(("ATK "+str(maxlv)), player), lambda state: False) + set_rule(multiworld.get_location(("HP "+str(maxlv)), player), lambda state: False) + if maxlv == 9 or maxlv == 13 or maxlv == 17: + set_rule(multiworld.get_location(("DEF "+str(maxlv)), player), lambda state: False) + maxlv = 1 + while maxlv < 20: + while _undertale_can_level(multiworld.state, exp, maxlv): + maxlv += 1 + if multiworld.rando_stats[player]: + if curarea == "Old Home": + add_rule(multiworld.get_location(("ATK "+str(maxlv)), player), + lambda state: (state.can_reach("Old Home", "Region", player)), combine="or") + add_rule(multiworld.get_location(("HP "+str(maxlv)), player), + lambda state: (state.can_reach("Old Home", "Region", player)), combine="or") + if maxlv == 9 or maxlv == 13 or maxlv == 17: + add_rule(multiworld.get_location(("DEF "+str(maxlv)), player), + lambda state: (state.can_reach("Old Home", "Region", player)), combine="or") + elif curarea == "Snowdin Town": + add_rule(multiworld.get_location(("ATK "+str(maxlv)), player), + lambda state: (state.can_reach("Snowdin Town", "Region", player)), combine="or") + add_rule(multiworld.get_location(("HP "+str(maxlv)), player), + lambda state: (state.can_reach("Snowdin Town", "Region", player)), combine="or") + if maxlv == 9 or maxlv == 13 or maxlv == 17: + add_rule(multiworld.get_location(("DEF "+str(maxlv)), player), + lambda state: (state.can_reach("Snowdin Town", "Region", player)), combine="or") + elif curarea == "Waterfall": + add_rule(multiworld.get_location(("ATK "+str(maxlv)), player), + lambda state: (state.can_reach("Waterfall", "Region", player)), combine="or") + add_rule(multiworld.get_location(("HP "+str(maxlv)), player), + lambda state: (state.can_reach("Waterfall", "Region", player)), combine="or") + if maxlv == 9 or maxlv == 13 or maxlv == 17: + add_rule(multiworld.get_location(("DEF "+str(maxlv)), player), + lambda state: (state.can_reach("Waterfall", "Region", player)), combine="or") + elif curarea == "News Show": + add_rule(multiworld.get_location(("ATK "+str(maxlv)), player), + lambda state: (state.can_reach("News Show", "Region", player)), combine="or") + add_rule(multiworld.get_location(("HP "+str(maxlv)), player), + lambda state: (state.can_reach("News Show", "Region", player)), combine="or") + if maxlv == 9 or maxlv == 13 or maxlv == 17: + add_rule(multiworld.get_location(("DEF "+str(maxlv)), player), + lambda state: (state.can_reach("News Show", "Region", player)), combine="or") + elif curarea == "Core": + add_rule(multiworld.get_location(("ATK "+str(maxlv)), player), + lambda state: (state.can_reach("Core Exit", "Entrance", player)), combine="or") + add_rule(multiworld.get_location(("HP "+str(maxlv)), player), + lambda state: (state.can_reach("Core Exit", "Entrance", player)), combine="or") + if maxlv == 9 or maxlv == 13 or maxlv == 17: + add_rule(multiworld.get_location(("DEF "+str(maxlv)), player), + lambda state: (state.can_reach("Core Exit", "Entrance", player)), combine="or") + elif curarea == "Sans": + add_rule(multiworld.get_location(("ATK "+str(maxlv)), player), + lambda state: (state.can_reach("New Home Exit", "Entrance", player)), combine="or") + add_rule(multiworld.get_location(("HP "+str(maxlv)), player), + lambda state: (state.can_reach("New Home Exit", "Entrance", player)), combine="or") + if maxlv == 9 or maxlv == 13 or maxlv == 17: + add_rule(multiworld.get_location(("DEF "+str(maxlv)), player), + lambda state: (state.can_reach("New Home Exit", "Entrance", player)), combine="or") + if multiworld.rando_love[player]: + if curarea == "Old Home": + add_rule(multiworld.get_location(("LOVE "+str(maxlv)), player), + lambda state: ( state.can_reach("Old Home", "Region", player)), combine="or") + elif curarea == "Snowdin Town": + add_rule(multiworld.get_location(("LOVE "+str(maxlv)), player), + lambda state: (state.can_reach("Snowdin Town", "Region", player)), combine="or") + elif curarea == "Waterfall": + add_rule(multiworld.get_location(("LOVE "+str(maxlv)), player), + lambda state: (state.can_reach("Waterfall", "Region", player)), combine="or") + elif curarea == "News Show": + add_rule(multiworld.get_location(("LOVE "+str(maxlv)), player), + lambda state: (state.can_reach("News Show", "Region", player)), combine="or") + elif curarea == "Core": + add_rule(multiworld.get_location(("LOVE "+str(maxlv)), player), + lambda state: (state.can_reach("Core Exit", "Entrance", player)), combine="or") + elif curarea == "Sans": + add_rule(multiworld.get_location(("LOVE "+str(maxlv)), player), + lambda state: (state.can_reach("New Home Exit", "Entrance", player)), combine="or") + if curarea == "Old Home": + curarea = "Snowdin Town" + maxlv = 1 + exp = 407 + elif curarea == "Snowdin Town": + curarea = "Waterfall" + maxlv = 1 + exp = 1643 + elif curarea == "Waterfall": + curarea = "News Show" + maxlv = 1 + exp = 3320 + elif curarea == "News Show": + curarea = "Core" + maxlv = 1 + exp = 50000 + elif curarea == "Core": + curarea = "Sans" + maxlv = 1 + exp = 99999 + set_rule(multiworld.get_entrance("??? Exit", player), lambda state: state.has("FIGHT", player)) + set_rule(multiworld.get_location("Snowman", player), + lambda state: state.can_reach("Snowdin Town", "Region", player)) + if _undertale_is_route(multiworld.state, player, 1): + set_rule(multiworld.get_location("Donut Sale", player), + lambda state: state.has("ACT", player) and state.has("MERCY", player)) + set_rule(multiworld.get_location("Cider Sale", player), + lambda state: state.has("ACT", player) and state.has("MERCY", player)) + set_rule(multiworld.get_location("Ribbon Cracks", player), + lambda state: state.has("ACT", player) and state.has("MERCY", player)) + set_rule(multiworld.get_location("Toy Knife Edge", player), + lambda state: state.has("ACT", player) and state.has("MERCY", player)) + set_rule(multiworld.get_location("B.Scotch Pie Given", player), + lambda state: state.has("ACT", player) and state.has("MERCY", player)) + if _undertale_is_route(multiworld.state, player, 2) or _undertale_is_route(multiworld.state, player, 3): + set_rule(multiworld.get_location("Donut Sale", player), + lambda state: state.has("FIGHT", player)) + set_rule(multiworld.get_location("Cider Sale", player), + lambda state: state.has("FIGHT", player)) + set_rule(multiworld.get_location("Ribbon Cracks", player), + lambda state: state.has("FIGHT", player)) + set_rule(multiworld.get_location("Toy Knife Edge", player), + lambda state: state.has("FIGHT", player)) + set_rule(multiworld.get_location("B.Scotch Pie Given", player), + lambda state: state.has("FIGHT", player)) + if _undertale_is_route(multiworld.state, player, 0): + set_rule(multiworld.get_location("Donut Sale", player), + lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + set_rule(multiworld.get_location("Cider Sale", player), + lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + set_rule(multiworld.get_location("Ribbon Cracks", player), + lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + set_rule(multiworld.get_location("Toy Knife Edge", player), + lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + set_rule(multiworld.get_location("B.Scotch Pie Given", player), + lambda state: ((state.has("ACT", player) and state.has("MERCY", player)) or state.has("FIGHT", player))) + set_rule(multiworld.get_location("Mettaton Plot", player), + lambda state: state.can_reach("Core Exit", "Entrance", player)) + set_rule(multiworld.get_location("Bunny 1", player), + lambda state: state.can_reach("Snowdin Town", "Region", player)) + set_rule(multiworld.get_location("Bunny 2", player), + lambda state: state.can_reach("Snowdin Town", "Region", player)) + set_rule(multiworld.get_location("Bunny 3", player), + lambda state: state.can_reach("Snowdin Town", "Region", player)) + set_rule(multiworld.get_location("Bunny 4", player), + lambda state: state.can_reach("Snowdin Town", "Region", player)) + set_rule(multiworld.get_location("Astro 1", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Astro 2", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Gerson 1", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Gerson 2", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Gerson 3", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Gerson 4", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Present Knife", player), + lambda state: state.can_reach("New Home", "Region", player)) + set_rule(multiworld.get_location("Present Locket", player), + lambda state: state.can_reach("New Home", "Region", player)) + set_rule(multiworld.get_location("Left New Home Key", player), + lambda state: state.can_reach("New Home", "Region", player)) + set_rule(multiworld.get_location("Right New Home Key", player), + lambda state: state.can_reach("New Home", "Region", player)) + set_rule(multiworld.get_location("Trash Burger", player), + lambda state: state.can_reach("Core", "Region", player)) + set_rule(multiworld.get_location("Quiche Bench", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Tutu Hidden", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("Grass Shoes", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("TemmieShop 1", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("TemmieShop 2", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("TemmieShop 3", player), + lambda state: state.can_reach("Waterfall", "Region", player)) + set_rule(multiworld.get_location("TemmieShop 4", player), + lambda state: state.can_reach("Waterfall", "Region", player) and state.has("1000G", player, 2)) + set_rule(multiworld.get_location("Noodles Fridge", player), + lambda state: state.can_reach("Hotland", "Region", player)) + set_rule(multiworld.get_location("Pan Hidden", player), + lambda state: state.can_reach("Hotland", "Region", player)) + set_rule(multiworld.get_location("Bratty Catty 1", player), + lambda state: state.can_reach("News Show", "Region", player)) + set_rule(multiworld.get_location("Bratty Catty 2", player), + lambda state: state.can_reach("News Show", "Region", player)) + set_rule(multiworld.get_location("Bratty Catty 3", player), + lambda state: state.can_reach("News Show", "Region", player)) + set_rule(multiworld.get_location("Bratty Catty 4", player), + lambda state: state.can_reach("News Show", "Region", player)) + set_rule(multiworld.get_location("Burgerpants 1", player), + lambda state: state.can_reach("News Show", "Region", player)) + set_rule(multiworld.get_location("Burgerpants 2", player), + lambda state: state.can_reach("News Show", "Region", player)) + set_rule(multiworld.get_location("Burgerpants 3", player), + lambda state: state.can_reach("News Show", "Region", player)) + set_rule(multiworld.get_location("Burgerpants 4", player), + lambda state: state.can_reach("News Show", "Region", player)) + + +# Sets rules on completion condition +def set_completion_rules(multiworld: MultiWorld, player: int): + completion_requirements = lambda state: state.can_reach("New Home Exit", "Entrance", player) and state.has("FIGHT", player) + if _undertale_is_route(multiworld.state, player, 1): + completion_requirements = lambda state: state.can_reach("True Lab", "Region", player) + + multiworld.completion_condition[player] = lambda state: completion_requirements(state) diff --git a/worlds/undertale/__init__.py b/worlds/undertale/__init__.py new file mode 100644 index 000000000000..bf2a14901aec --- /dev/null +++ b/worlds/undertale/__init__.py @@ -0,0 +1,228 @@ +from .Items import UndertaleItem, item_table, required_armor, required_weapons, non_key_items, key_items, \ + junk_weights_all, plot_items, junk_weights_neutral, junk_weights_pacifist, junk_weights_genocide +from .Locations import UndertaleAdvancement, advancement_table, exclusion_table +from .Regions import undertale_regions, link_undertale_areas +from .Rules import set_rules, set_completion_rules +from worlds.generic.Rules import exclusion_rules +from BaseClasses import Region, Entrance, Tutorial, Item +from .Options import undertale_options +from worlds.AutoWorld import World, WebWorld +from worlds.LauncherComponents import Component, components, Type +from multiprocessing import Process + + +def run_client(): + print('running undertale client') + from UndertaleClient import main # lazy import + p = Process(target=main) + p.start() + + +components.append(Component("Undertale Client", "UndertaleClient")) + + +def data_path(file_name: str): + import pkgutil + return pkgutil.get_data(__name__, "data/" + file_name) + + +class UndertaleWeb(WebWorld): + tutorials = [Tutorial( + "Multiworld Setup Tutorial", + "A guide to setting up the Archipelago Undertale software on your computer. This guide covers " + "single-player, multiworld, and related software.", + "English", + "undertale_en.md", + "undertale/en", + ["Mewlif"] + )] + + +class UndertaleWorld(World): + """ + Undertale is an RPG where every choice you make matters. You could choose to hurt all the enemies, eventually + causing genocide of the monster species. Or you can spare all the enemies, befriending them and freeing them + from their underground prison. + """ + game = "Undertale" + option_definitions = undertale_options + topology_present = True + web = UndertaleWeb() + + 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 advancement_table.items()} + + data_version = 5 + + def _get_undertale_data(self): + return { + "world_seed": self.multiworld.per_slot_randoms[self.player].getrandbits(32), + "seed_name": self.multiworld.seed_name, + "player_name": self.multiworld.get_player_name(self.player), + "player_id": self.player, + "client_version": self.required_client_version, + "race": self.multiworld.is_race, + "route": self.multiworld.route_required[self.player].current_key, + "temy_armor_include": bool(self.multiworld.temy_include[self.player].value), + "only_flakes": bool(self.multiworld.only_flakes[self.player].value), + "no_equips": bool(self.multiworld.no_equips[self.player].value), + "key_hunt": bool(self.multiworld.key_hunt[self.player].value), + "key_pieces": self.multiworld.key_pieces[self.player].value, + "rando_love": bool(self.multiworld.rando_love[self.player].value), + "rando_stats": bool(self.multiworld.rando_stats[self.player].value), + "prog_armor": bool(self.multiworld.prog_armor[self.player].value), + "prog_weapons": bool(self.multiworld.prog_weapons[self.player].value), + "rando_item_button": bool(self.multiworld.rando_item_button[self.player].value) + } + + def create_items(self): + self.multiworld.get_location("Undyne Date", self.player).place_locked_item(self.create_item("Undyne Date")) + self.multiworld.get_location("Alphys Date", self.player).place_locked_item(self.create_item("Alphys Date")) + self.multiworld.get_location("Papyrus Date", self.player).place_locked_item(self.create_item("Papyrus Date")) + # Generate item pool + itempool = [] + if self.multiworld.route_required[self.player] == "all_routes": + junk_pool = junk_weights_all.copy() + elif self.multiworld.route_required[self.player] == "genocide": + junk_pool = junk_weights_genocide.copy() + elif self.multiworld.route_required[self.player] == "neutral": + junk_pool = junk_weights_neutral.copy() + elif self.multiworld.route_required[self.player] == "pacifist": + junk_pool = junk_weights_pacifist.copy() + else: + junk_pool = junk_weights_all.copy() + # Add all required progression items + for name, num in key_items.items(): + itempool += [name] * num + for name, num in required_armor.items(): + itempool += [name] * num + for name, num in required_weapons.items(): + itempool += [name] * num + for name, num in non_key_items.items(): + itempool += [name] * num + if self.multiworld.rando_item_button[self.player]: + itempool += ["ITEM"] + else: + self.multiworld.push_precollected(self.create_item("ITEM")) + self.multiworld.push_precollected(self.create_item("FIGHT")) + self.multiworld.push_precollected(self.create_item("ACT")) + chosen_key_start = self.multiworld.per_slot_randoms[self.player].choice(["Ruins Key", "Snowdin Key", "Waterfall Key", "Hotland Key"]) + self.multiworld.push_precollected(self.create_item(chosen_key_start)) + itempool.remove(chosen_key_start) + self.multiworld.push_precollected(self.create_item("MERCY")) + if self.multiworld.route_required[self.player] == "genocide": + itempool = [item for item in itempool if item != "Popato Chisps" and item != "Stained Apron" and + item != "Nice Cream" and item != "Hot Cat" and item != "Hot Dog...?" and item != "Punch Card"] + elif self.multiworld.route_required[self.player] == "neutral": + itempool = [item for item in itempool if item != "Popato Chisps" and item != "Hot Cat" and + item != "Hot Dog...?"] + if self.multiworld.route_required[self.player] == "pacifist" or \ + self.multiworld.route_required[self.player] == "all_routes": + itempool += ["Undyne Letter EX"] + else: + itempool.remove("Complete Skeleton") + itempool.remove("Fish") + itempool.remove("DT Extractor") + itempool.remove("Hush Puppy") + if self.multiworld.key_hunt[self.player]: + itempool += ["Key Piece"] * self.multiworld.key_pieces[self.player].value + else: + itempool += ["Left Home Key"] + itempool += ["Right Home Key"] + if not self.multiworld.rando_love[self.player] or \ + (self.multiworld.route_required[self.player] != "genocide" and + self.multiworld.route_required[self.player] != "all_routes"): + itempool = [item for item in itempool if not item == "LOVE"] + if not self.multiworld.rando_stats[self.player] or \ + (self.multiworld.route_required[self.player] != "genocide" and + self.multiworld.route_required[self.player] != "all_routes"): + itempool = [item for item in itempool if not (item == "ATK Up" or item == "DEF Up" or item == "HP Up")] + if self.multiworld.temy_include[self.player]: + itempool += ["temy armor"] + if self.multiworld.no_equips[self.player]: + itempool = [item for item in itempool if item not in required_armor and item not in required_weapons] + else: + if self.multiworld.prog_armor[self.player]: + itempool = [item if (item not in required_armor and not item == "temy armor") else + "Progressive Armor" for item in itempool] + if self.multiworld.prog_weapons[self.player]: + itempool = [item if item not in required_weapons else "Progressive Weapons" for item in itempool] + if self.multiworld.route_required[self.player] == "genocide" or \ + self.multiworld.route_required[self.player] == "all_routes": + if not self.multiworld.only_flakes[self.player]: + itempool += ["Snowman Piece"] * 2 + if not self.multiworld.no_equips[self.player]: + itempool = ["Real Knife" if item == "Worn Dagger" else "The Locket" + if item == "Heart Locket" else item for item in itempool] + if self.multiworld.only_flakes[self.player]: + itempool = [item for item in itempool if item not in non_key_items] + # Choose locations to automatically exclude based on settings + exclusion_pool = set() + exclusion_pool.update(exclusion_table[self.multiworld.route_required[self.player].current_key]) + if not self.multiworld.rando_love[self.player] or \ + (self.multiworld.route_required[self.player] != "genocide" and + self.multiworld.route_required[self.player] != "all_routes"): + exclusion_pool.update(exclusion_table["NoLove"]) + if not self.multiworld.rando_stats[self.player] or \ + (self.multiworld.route_required[self.player] != "genocide" and + self.multiworld.route_required[self.player] != "all_routes"): + exclusion_pool.update(exclusion_table["NoStats"]) + + # Choose locations to automatically exclude based on settings + exclusion_checks = set() + exclusion_checks.update(["Nicecream Punch Card", "Hush Trade"]) + exclusion_rules(self.multiworld, self.player, exclusion_checks) + + # Fill remaining items with randomly generated junk or Temmie Flakes + if not self.multiworld.only_flakes[self.player]: + itempool += self.multiworld.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), + k=len(self.location_names)-len(itempool)-len(exclusion_pool)) + else: + itempool += ["Temmie Flakes"] * (len(self.location_names) - len(itempool) - len(exclusion_pool)) + # Convert itempool into real items + itempool = [item for item in map(lambda name: self.create_item(name), itempool)] + + self.multiworld.itempool += itempool + + def set_rules(self): + set_rules(self.multiworld, self.player) + set_completion_rules(self.multiworld, self.player) + + def create_regions(self): + def UndertaleRegion(region_name: str, exits=[]): + ret = Region(region_name, self.player, self.multiworld) + ret.locations = [UndertaleAdvancement(self.player, loc_name, loc_data.id, ret) + for loc_name, loc_data in advancement_table.items() + if loc_data.region == region_name and + (loc_name not in exclusion_table["NoStats"] or + (self.multiworld.rando_stats[self.player] and + (self.multiworld.route_required[self.player] == "genocide" or + self.multiworld.route_required[self.player] == "all_routes"))) and + (loc_name not in exclusion_table["NoLove"] or + (self.multiworld.rando_love[self.player] and + (self.multiworld.route_required[self.player] == "genocide" or + self.multiworld.route_required[self.player] == "all_routes"))) and + loc_name not in exclusion_table[self.multiworld.route_required[self.player].current_key]] + for exit in exits: + ret.exits.append(Entrance(self.player, exit, ret)) + return ret + + self.multiworld.regions += [UndertaleRegion(*r) for r in undertale_regions] + link_undertale_areas(self.multiworld, self.player) + + def fill_slot_data(self): + slot_data = self._get_undertale_data() + for option_name in undertale_options: + option = getattr(self.multiworld, option_name)[self.player] + if (option_name == "rando_love" or option_name == "rando_stats") and \ + self.multiworld.route_required[self.player] != "genocide" and \ + self.multiworld.route_required[self.player] != "all_routes": + option.value = False + if slot_data.get(option_name, None) is None and type(option.value) in {str, int}: + slot_data[option_name] = int(option.value) + return slot_data + + def create_item(self, name: str) -> Item: + item_data = item_table[name] + item = UndertaleItem(name, item_data.classification, item_data.code, self.player) + return item diff --git a/worlds/undertale/data/patch.bsdiff b/worlds/undertale/data/patch.bsdiff new file mode 100644 index 000000000000..67d9dd1e1135 Binary files /dev/null and b/worlds/undertale/data/patch.bsdiff differ diff --git a/worlds/undertale/docs/en_Undertale.md b/worlds/undertale/docs/en_Undertale.md new file mode 100644 index 000000000000..1cb9698fa3c5 --- /dev/null +++ b/worlds/undertale/docs/en_Undertale.md @@ -0,0 +1,21 @@ +# Undertale + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a +config file. + +## What is considered a location check in Undertale? + +Location checks in Undertale are all the spots in the game where you can get an item. Exceptions are Dog Residue, +the Nicecream bought in Hotland, and anything you cannot get in your chosen route. + +## When the player receives an item, what happens? + +When the player receives an item in Undertale, it will go into their inventory if they have space, otherwise it will +wait until they do have space. That includes items that don't appear in your inventory. + +## What is the victory condition? + +Victory is achieved when the player completes their chosen route. If they chose `all_routes` then they need to complete +every major route in the game, those being `Pacifist`, `Neutral`, and `Genocide`. \ No newline at end of file diff --git a/worlds/undertale/docs/undertale_en.md b/worlds/undertale/docs/undertale_en.md new file mode 100644 index 000000000000..32420a3e7e36 --- /dev/null +++ b/worlds/undertale/docs/undertale_en.md @@ -0,0 +1,33 @@ +# Undertale Randomizer Setup Guide + +### Required Software + +- Undertale from the [Steam page](https://store.steampowered.com/app/391540) +- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) + - (select `Undertale Client` during installation.) + +### First time setup + +Start the Undertale client, and in the bottom text box, input `/auto_patch (Input your Undertale install directory here)` (It is usually located at `C:\Program Files\Steam\steamapps\Undertale`, but it can be different, you can more easily find the directory +by opening the Undertale directory through Steam), it will then make an Undertale folder that will be created in the +Archipelago install location. That contains the version of Undertale you will use for Archipelago. (You will need to +redo this step when updating Archipelago.) + +### Connect to the MultiServer + +Make sure both Undertale and its client are running. (Undertale will ask for a saveslot, it can be 1 through 99, none +of the slots will overwrite your vanilla save, although you may want to make a backup just in case.) + +In the top text box of the client, type the +`Ip Address` (or `Hostname`) and `Port` separated with a `:` symbol. (Ex. `archipelago.gg:38281`) + +The client will then ask for the slot name, input that in the text box at the bottom of the client. + +### Play the game + +When the console tells you that you have joined the room, you're all set. Congratulations on successfully joining a +multiworld game! + +### Where do I get a YAML file? + +You can customize your settings by visiting the [Undertale Player Settings Page](/games/Undertale/player-settings)