diff --git a/worlds/jakanddaxter/Client.py b/worlds/jakanddaxter/Client.py index 9b2198d72b..c3e2ba9c62 100644 --- a/worlds/jakanddaxter/Client.py +++ b/worlds/jakanddaxter/Client.py @@ -4,6 +4,7 @@ import sys import json import subprocess from logging import Logger +from datetime import datetime import colorama @@ -139,6 +140,11 @@ class JakAndDaxterContext(CommonContext): else: orbsanity_bundle = 1 + # Connected packet is unaware of starting inventory or if player is returning to an existing game. + # Set initial item count to 0 if it hasn't been set higher by a ReceivedItems packet yet. + if not self.repl.received_initial_items and self.repl.initial_item_count < 0: + self.repl.initial_item_count = 0 + create_task_log_exception( self.repl.setup_options(orbsanity_option, orbsanity_bundle, @@ -147,7 +153,10 @@ class JakAndDaxterContext(CommonContext): slot_data["lava_tube_cell_count"], slot_data["citizen_orb_trade_amount"], slot_data["oracle_orb_trade_amount"], - slot_data["jak_completion_condition"])) + slot_data["trap_effect_duration"], + slot_data["jak_completion_condition"], + slot_data["slot_name"][:16], + slot_data["slot_seed"][:8])) # Because Orbsanity and the orb traders in the game are intrinsically linked, we need the server # to track our trades at all times to support async play. "Retrieved" will tell us the orbs we lost, @@ -169,6 +178,16 @@ class JakAndDaxterContext(CommonContext): create_task_log_exception(self.repl.subtract_traded_orbs(orbs_traded)) if cmd == "ReceivedItems": + if not self.repl.received_initial_items: + + # ReceivedItems packet should set the initial item count to > 0, even if already set to 0 by the + # Connected packet. Then we should tell the game to update the title screen, telling the player + # to wait while we process the initial items. This is skipped if no initial items are sent. + self.repl.initial_item_count = len(args["items"]) + create_task_log_exception(self.repl.send_connection_status("wait")) + + # This enumeration should run on every ReceivedItems packet, + # regardless of it being on initial connection or midway through a game. for index, item in enumerate(args["items"], start=args["index"]): logger.debug(f"index: {str(index)}, item: {str(item)}") self.repl.item_inbox[index] = item @@ -366,7 +385,7 @@ def find_root_directory(ctx: JakAndDaxterContext): for mod in mod_sources[src].keys(): if mod == "archipelagoal": archipelagoal_source = src - # TODO - We could verify the right version is installed. Do we need to? + # Using this file, we could verify the right version is installed, but we don't need to. if archipelagoal_source is None: msg = (f"Unable to locate the ArchipelaGOAL install directory: " f"The ArchipelaGOAL mod is not installed in the OpenGOAL Launcher!\n" @@ -458,7 +477,8 @@ async def run_game(ctx: JakAndDaxterContext): # The game freezes if text is inadvertently selected in the stdout/stderr data streams. Let's pipe those # streams to a file, and let's not clutter the screen with another console window. - log_path = os.path.join(Utils.user_path("logs"), "JakAndDaxterGame.txt") + timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M_%S") + log_path = os.path.join(Utils.user_path("logs"), f"JakAndDaxterGame_{timestamp}.txt") log_path = os.path.normpath(log_path) with open(log_path, "w") as log_file: gk_process = subprocess.Popen( diff --git a/worlds/jakanddaxter/Items.py b/worlds/jakanddaxter/Items.py index 8a548ad66d..662ec2b715 100644 --- a/worlds/jakanddaxter/Items.py +++ b/worlds/jakanddaxter/Items.py @@ -100,6 +100,25 @@ move_item_table = { # 24040: "Orb Cache at Start of Launch Pad Room", } +# These are trap items. Their Item ID is to be subtracted from the base game ID. They do not have corresponding +# game locations because they are intended to replace other items that have been marked as filler. +trap_item_table = { + 1: "Trip Trap", + 2: "Slippery Trap", + 3: "Gravity Trap", + 4: "Camera Trap", + 5: "Darkness Trap", + 6: "Earthquake Trap", + 7: "Teleport Trap", + 8: "Despair Trap", + 9: "Pacifism Trap", + 10: "Ecoless Trap", + 11: "Health Trap", + 12: "Ledge Trap", + 13: "Zoomer Trap", + 14: "Mirror Trap", +} + # All Items # While we're here, do all the ID conversions needed. item_table = { @@ -108,5 +127,6 @@ item_table = { **{Specials.to_ap_id(k): special_item_table[k] for k in special_item_table}, **{Caches.to_ap_id(k): move_item_table[k] for k in move_item_table}, **{Orbs.to_ap_id(k): orb_item_table[k] for k in orb_item_table}, + **{jak1_max - k: trap_item_table[k] for k in trap_item_table}, jak1_max: "Green Eco Pill" # Filler item. } diff --git a/worlds/jakanddaxter/Options.py b/worlds/jakanddaxter/Options.py index 664b619c0d..fc0e4c66cb 100644 --- a/worlds/jakanddaxter/Options.py +++ b/worlds/jakanddaxter/Options.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle +from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle, OptionSet +from .Items import trap_item_table class EnableMoveRandomizer(Toggle): @@ -157,6 +158,51 @@ class OracleOrbTradeAmount(Range): default = 120 +class FillerPowerCellsReplacedWithTraps(Range): + """ + The number of filler power cells that will be replaced with traps. This does not affect the number of progression + power cells. + + If this value is greater than the number of filler power cells, then they will all be replaced with traps. + """ + display_name = "Filler Power Cells Replaced With Traps" + range_start = 0 + range_end = 100 + default = 0 + + +class FillerOrbBundlesReplacedWithTraps(Range): + """ + The number of filler orb bundles that will be replaced with traps. This does not affect the number of progression + orb bundles. This only applies if "Enable Orbsanity" is set to "Per Level" or "Global." + + If this value is greater than the number of filler orb bundles, then they will all be replaced with traps. + """ + display_name = "Filler Orb Bundles Replaced With Traps" + range_start = 0 + range_end = 2000 + default = 0 + + +class TrapEffectDuration(Range): + """ + The length of time, in seconds, that a trap effect lasts. + """ + display_name = "Trap Effect Duration" + range_start = 5 + range_end = 60 + default = 30 + + +class ChosenTraps(OptionSet): + """ + The list of traps that will be randomly added to the item pool. If the list is empty, no traps are created. + """ + display_name = "Chosen Traps" + default = {trap for trap in trap_item_table.values()} + valid_keys = {trap for trap in trap_item_table.values()} + + class CompletionCondition(Choice): """Set the goal for completing the game.""" display_name = "Completion Condition" @@ -183,5 +229,9 @@ class JakAndDaxterOptions(PerGameCommonOptions): require_punch_for_klaww: RequirePunchForKlaww citizen_orb_trade_amount: CitizenOrbTradeAmount oracle_orb_trade_amount: OracleOrbTradeAmount + filler_power_cells_replaced_with_traps: FillerPowerCellsReplacedWithTraps + filler_orb_bundles_replaced_with_traps: FillerOrbBundlesReplacedWithTraps + trap_effect_duration: TrapEffectDuration + chosen_traps: ChosenTraps jak_completion_condition: CompletionCondition start_inventory_from_pool: StartInventoryPool diff --git a/worlds/jakanddaxter/__init__.py b/worlds/jakanddaxter/__init__.py index e28bb9cbd4..8299ee507e 100644 --- a/worlds/jakanddaxter/__init__.py +++ b/worlds/jakanddaxter/__init__.py @@ -1,3 +1,4 @@ +import typing from typing import Any, ClassVar, Callable from math import ceil import Utils @@ -23,8 +24,10 @@ from .Items import (JakAndDaxterItem, scout_item_table, special_item_table, move_item_table, - orb_item_table) + orb_item_table, + trap_item_table) from .Levels import level_table, level_table_with_global +from .regs.RegionBase import JakAndDaxterRegion from .locs import (CellLocations as Cells, ScoutLocations as Scouts, SpecialLocations as Specials, @@ -99,6 +102,12 @@ class JakAndDaxterWebWorld(WebWorld): Options.CitizenOrbTradeAmount, Options.OracleOrbTradeAmount, ]), + OptionGroup("Traps", [ + Options.FillerPowerCellsReplacedWithTraps, + Options.FillerOrbBundlesReplacedWithTraps, + Options.TrapEffectDuration, + Options.ChosenTraps, + ]), ] @@ -114,7 +123,7 @@ class JakAndDaxterWorld(World): """ # ID, name, version game = jak1_name - required_client_version = (0, 6, 0) + required_client_version = (0, 5, 0) # Options settings: ClassVar[JakAndDaxterSettings] @@ -134,6 +143,7 @@ class JakAndDaxterWorld(World): "Specials": set(special_item_table.values()), "Moves": set(move_item_table.values()), "Precursor Orbs": set(orb_item_table.values()), + "Traps": set(trap_item_table.values()), } location_name_groups = { "Power Cells": set(cell_location_table.values()), @@ -197,10 +207,19 @@ class JakAndDaxterWorld(World): # These functions and variables are Options-driven, keep them as instance variables here so that we don't clog up # the seed generation routines with options checking. So we set these once, and then just use them as needed. can_trade: Callable[[CollectionState, int, int | None], bool] + total_orbs: int = 2000 orb_bundle_item_name: str = "" orb_bundle_size: int = 0 total_trade_orbs: int = 0 + total_prog_orb_bundles: int = 0 + total_trap_orb_bundles: int = 0 + total_filler_orb_bundles: int = 0 + total_power_cells: int = 101 + total_prog_cells: int = 0 + total_trap_cells: int = 0 + total_filler_cells: int = 0 power_cell_thresholds: list[int] = [] + chosen_traps: list[str] = [] # Handles various options validation, rules enforcement, and caching of important information. def generate_early(self) -> None: @@ -224,6 +243,7 @@ class JakAndDaxterWorld(World): # For the fairness of other players in a multiworld game, enforce some friendly limitations on our options, # so we don't cause chaos during seed generation. These friendly limits should **guarantee** a successful gen. + # We would have done this earlier, but we needed to sort the power cell thresholds first. enforce_friendly_options = Utils.get_settings()["jakanddaxter_options"]["enforce_friendly_options"] if enforce_friendly_options: if self.multiworld.players > 1: @@ -233,6 +253,16 @@ class JakAndDaxterWorld(World): from .Rules import enforce_singleplayer_limits enforce_singleplayer_limits(self) + # Calculate the number of power cells needed for full region access, the number being replaced by traps, + # and the number of remaining filler. + if self.options.jak_completion_condition == Options.CompletionCondition.option_open_100_cell_door: + self.total_prog_cells = 100 + else: + self.total_prog_cells = max(self.power_cell_thresholds[:3]) + non_prog_cells = self.total_power_cells - self.total_prog_cells + self.total_trap_cells = min(self.options.filler_power_cells_replaced_with_traps.value, non_prog_cells) + self.total_filler_cells = non_prog_cells - self.total_trap_cells + # Verify that we didn't overload the trade amounts with more orbs than exist in the world. # This is easy to do by accident even in a singleplayer world. self.total_trade_orbs = (9 * self.options.citizen_orb_trade_amount) + (6 * self.options.oracle_orb_trade_amount) @@ -250,6 +280,18 @@ class JakAndDaxterWorld(World): self.orb_bundle_size = 0 self.orb_bundle_item_name = "" + # Calculate the number of orb bundles needed for trades, the number being replaced by traps, + # and the number of remaining filler. If Orbsanity is off, default values of 0 will prevail for all. + if self.orb_bundle_size > 0: + total_orb_bundles = self.total_orbs // self.orb_bundle_size + self.total_prog_orb_bundles = ceil(self.total_trade_orbs / self.orb_bundle_size) + non_prog_orb_bundles = total_orb_bundles - self.total_prog_orb_bundles + self.total_trap_orb_bundles = min(self.options.filler_orb_bundles_replaced_with_traps.value, + non_prog_orb_bundles) + self.total_filler_orb_bundles = non_prog_orb_bundles - self.total_trap_orb_bundles + + self.chosen_traps = list(self.options.chosen_traps.value) + # Options drive which trade rules to use, so they need to be setup before we create_regions. from .Rules import set_orb_trade_rule set_orb_trade_rule(self) @@ -265,27 +307,15 @@ class JakAndDaxterWorld(World): # Helper function to reuse some nasty if/else trees. This outputs a list of pairs of item count and classification. # For instance, not all 101 power cells need to be marked progression if you only need 72 to beat the game. So we # will have 72 Progression Power Cells, and 29 Filler Power Cells. - def item_type_helper(self, item) -> list[tuple[int, ItemClass]]: + def item_type_helper(self, item: int) -> list[tuple[int, ItemClass]]: counts_and_classes: list[tuple[int, ItemClass]] = [] - # Make 101 Power Cells. We only want AP's Progression Fill routine to handle the amount of cells we need + # Make N Power Cells. We only want AP's Progression Fill routine to handle the amount of cells we need # to reach the furthest possible region. Even for early completion goals, all areas in the game must be # reachable or generation will fail. TODO - Option-driven region creation would be an enormous refactor. if item in range(jak1_id, jak1_id + Scouts.fly_offset): - - # If for some unholy reason we don't have the list of power cell thresholds, have a fallback plan. - if self.power_cell_thresholds: - prog_count = max(self.power_cell_thresholds[:3]) - non_prog_count = 101 - prog_count - - if self.options.jak_completion_condition == Options.CompletionCondition.option_open_100_cell_door: - counts_and_classes.append((100, ItemClass.progression_skip_balancing)) - counts_and_classes.append((1, ItemClass.filler)) - else: - counts_and_classes.append((prog_count, ItemClass.progression_skip_balancing)) - counts_and_classes.append((non_prog_count, ItemClass.filler)) - else: - counts_and_classes.append((101, ItemClass.progression_skip_balancing)) + counts_and_classes.append((self.total_prog_cells, ItemClass.progression_skip_balancing)) + counts_and_classes.append((self.total_filler_cells, ItemClass.filler)) # Make 7 Scout Flies per level. elif item in range(jak1_id + Scouts.fly_offset, jak1_id + Specials.special_offset): @@ -299,24 +329,17 @@ class JakAndDaxterWorld(World): elif item in range(jak1_id + Caches.orb_cache_offset, jak1_id + Orbs.orb_offset): counts_and_classes.append((1, ItemClass.progression | ItemClass.useful)) - # Make N Precursor Orb bundles, where N is 2000 // bundle size. Like Power Cells, only a fraction of these will - # be marked as Progression with the remainder as Filler, but they are still entirely fungible. - elif item in range(jak1_id + Orbs.orb_offset, jak1_max): + # Make N Precursor Orb bundles. Like Power Cells, only a fraction of these will be marked as Progression + # with the remainder as Filler, but they are still entirely fungible. + elif item in range(jak1_id + Orbs.orb_offset, jak1_max - max(trap_item_table)): + counts_and_classes.append((self.total_prog_orb_bundles, ItemClass.progression_skip_balancing)) + counts_and_classes.append((self.total_filler_orb_bundles, ItemClass.filler)) - # Don't divide by zero! - if self.orb_bundle_size > 0: - item_count = 2000 // self.orb_bundle_size # Integer division here, bundle size is a factor of 2000. + # We will manually create trap items as needed. + elif item in range(jak1_max - max(trap_item_table), jak1_max): + counts_and_classes.append((0, ItemClass.trap)) - # Have enough bundles to do all trades. The rest can be filler. - prog_count = ceil(self.total_trade_orbs / self.orb_bundle_size) - non_prog_count = item_count - prog_count - - counts_and_classes.append((prog_count, ItemClass.progression_skip_balancing)) - counts_and_classes.append((non_prog_count, ItemClass.filler)) - else: - counts_and_classes.append((0, ItemClass.filler)) # No orbs in a bundle means no bundles. - - # Under normal circumstances, we create 0 green eco fillers. We will manually create filler items as needed. + # We will manually create filler items as needed. elif item == jak1_max: counts_and_classes.append((0, ItemClass.filler)) @@ -327,6 +350,7 @@ class JakAndDaxterWorld(World): return counts_and_classes def create_items(self) -> None: + items_made: int = 0 for item_name in self.item_name_to_id: item_id = self.item_name_to_id[item_name] @@ -336,21 +360,45 @@ class JakAndDaxterWorld(World): if item_name in self.item_name_groups["Moves"] and not self.options.enable_move_randomizer: self.multiworld.push_precollected(self.create_item(item_name)) self.multiworld.itempool.append(self.create_filler()) + items_made += 1 continue # Handle Orbsanity option. # If it is OFF, don't add any orb bundles to the item pool, period. # If it is ON, don't add any orb bundles that don't match the chosen option. if (item_name in self.item_name_groups["Precursor Orbs"] - and (self.options.enable_orbsanity == Options.EnableOrbsanity.option_off - or item_name != self.orb_bundle_item_name)): + and (self.options.enable_orbsanity == Options.EnableOrbsanity.option_off + or item_name != self.orb_bundle_item_name)): continue - # In every other scenario, do this. Not all items with the same name will have the same classification. + # Skip Traps for now. + if item_name in self.item_name_groups["Traps"]: + continue + + # In almost every other scenario, do this. Not all items with the same name will have the same item class. counts_and_classes = self.item_type_helper(item_id) for (count, classification) in counts_and_classes: self.multiworld.itempool += [JakAndDaxterItem(item_name, classification, item_id, self.player) for _ in range(count)] + items_made += count + + # Handle Traps (for real). + # Manually fill the item pool with a random assortment of trap items, equal to the sum of + # total_trap_cells + total_trap_orb_bundles. Only do this if one or more traps have been selected. + if len(self.chosen_traps) > 0: + total_traps = self.total_trap_cells + self.total_trap_orb_bundles + for _ in range(total_traps): + trap_name = self.random.choice(self.chosen_traps) + self.multiworld.itempool.append(self.create_item(trap_name)) + items_made += total_traps + + # Handle Unfilled Locations. + # Add an amount of filler items equal to the number of locations yet to be filled. + # This is the final set of items we will add to the pool. + all_regions = self.multiworld.get_regions(self.player) + total_locations = sum(reg.location_count for reg in typing.cast(list[JakAndDaxterRegion], all_regions)) + total_filler = total_locations - items_made + self.multiworld.itempool += [self.create_filler() for _ in range(total_filler)] def create_item(self, name: str) -> Item: item_id = self.item_name_to_id[name] @@ -413,14 +461,24 @@ class JakAndDaxterWorld(World): return change def fill_slot_data(self) -> dict[str, Any]: - return self.options.as_dict("enable_move_randomizer", - "enable_orbsanity", - "global_orbsanity_bundle_size", - "level_orbsanity_bundle_size", - "fire_canyon_cell_count", - "mountain_pass_cell_count", - "lava_tube_cell_count", - "citizen_orb_trade_amount", - "oracle_orb_trade_amount", - "jak_completion_condition", - "require_punch_for_klaww",) + slot_dict = { + "slot_name": self.player_name, + "slot_seed": self.multiworld.seed_name, + } + options_dict = self.options.as_dict("enable_move_randomizer", + "enable_orbsanity", + "global_orbsanity_bundle_size", + "level_orbsanity_bundle_size", + "fire_canyon_cell_count", + "mountain_pass_cell_count", + "lava_tube_cell_count", + "citizen_orb_trade_amount", + "oracle_orb_trade_amount", + "filler_power_cells_replaced_with_traps", + "filler_orb_bundles_replaced_with_traps", + "trap_effect_duration", + "chosen_traps", + "jak_completion_condition", + "require_punch_for_klaww", + ) + return {**slot_dict, **options_dict} diff --git a/worlds/jakanddaxter/client/MemoryReader.py b/worlds/jakanddaxter/client/MemoryReader.py index 92ce53c405..2fa35e1c3c 100644 --- a/worlds/jakanddaxter/client/MemoryReader.py +++ b/worlds/jakanddaxter/client/MemoryReader.py @@ -28,7 +28,7 @@ sizeof_float = 4 # ***************************************************************************** # **** This number must match (-> *ap-info-jak1* version) in ap-struct.gc! **** # ***************************************************************************** -expected_memory_version = 4 +expected_memory_version = 5 # IMPORTANT: OpenGOAL memory structures are particular about the alignment, in memory, of member elements according to @@ -104,6 +104,11 @@ memory_version_offset = offsets.define(sizeof_uint32) # Connection status to AP server (not the game!) server_connection_offset = offsets.define(sizeof_uint8) +slot_name_offset = offsets.define(sizeof_uint8, 16) +slot_seed_offset = offsets.define(sizeof_uint8, 8) + +# Trap information. +trap_duration_offset = offsets.define(sizeof_float) # The End. end_marker_offset = offsets.define(sizeof_uint8, 4) diff --git a/worlds/jakanddaxter/client/ReplClient.py b/worlds/jakanddaxter/client/ReplClient.py index 68f57829ea..e7661fad04 100644 --- a/worlds/jakanddaxter/client/ReplClient.py +++ b/worlds/jakanddaxter/client/ReplClient.py @@ -16,7 +16,7 @@ from asyncio import StreamReader, StreamWriter, Lock from NetUtils import NetworkItem from ..GameID import jak1_id, jak1_max -from ..Items import item_table +from ..Items import item_table, trap_item_table from ..locs import ( OrbLocations as Orbs, CellLocations as Cells, @@ -58,6 +58,7 @@ class JakAndDaxterReplClient: initiated_connect: bool = False # Signals when user tells us to try reconnecting. received_deathlink: bool = False balanced_orbs: bool = False + received_initial_items = False # The REPL client needs the REPL/compiler process running, but that process # also needs the game running. Therefore, the REPL client needs both running. @@ -66,6 +67,7 @@ class JakAndDaxterReplClient: item_inbox: dict[int, NetworkItem] = {} inbox_index = 0 + initial_item_count = -1 # New games have 0 items, so initialize this to -1. json_message_queue: Queue[JsonMessageData] = queue.Queue() # Logging callbacks @@ -131,6 +133,13 @@ class JakAndDaxterReplClient: await self.save_data() self.inbox_index += 1 + # When connecting the game to the AP server on the title screen, we may be processing items from starting + # inventory or items received in an async game. Once we are done, tell the player that we are ready to start. + if not self.received_initial_items and self.initial_item_count >= 0: + if self.inbox_index == self.initial_item_count: + self.received_initial_items = True + await self.send_connection_status("ready") + if self.received_deathlink: await self.receive_deathlink() self.received_deathlink = False @@ -266,6 +275,14 @@ class JakAndDaxterReplClient: result = "".join([c if c in ALLOWED_CHARACTERS else "?" for c in text[:32]]).upper() return f"\"{result}\"" + # Like sanitize_game_text, but the settings file will NOT allow any whitespace in the slot_name or slot_seed data. + # And don't replace any chars with "?" for good measure. + @staticmethod + def sanitize_file_text(text: str) -> str: + allowed_chars_no_space = ALLOWED_CHARACTERS - {" "} + result = "".join([c if c in allowed_chars_no_space else "" for c in text[:16]]).upper() + return f"\"{result}\"" + # Pushes a JsonMessageData object to the json message queue to be processed during the repl main_tick def queue_game_text(self, my_item_name, my_item_finder, their_item_name, their_item_owner): self.json_message_queue.put(JsonMessageData(my_item_name, my_item_finder, their_item_name, their_item_owner)) @@ -296,8 +313,10 @@ class JakAndDaxterReplClient: await self.receive_special(ap_id) elif ap_id in range(jak1_id + Caches.orb_cache_offset, jak1_id + Orbs.orb_offset): await self.receive_move(ap_id) - elif ap_id in range(jak1_id + Orbs.orb_offset, jak1_max): + elif ap_id in range(jak1_id + Orbs.orb_offset, jak1_max - max(trap_item_table)): await self.receive_precursor_orb(ap_id) # Ponder the Orbs. + elif ap_id in range(jak1_max - max(trap_item_table), jak1_max): + await self.receive_trap(ap_id) elif ap_id == jak1_max: await self.receive_green_eco() # Ponder why I chose to do ID's this way. else: @@ -363,6 +382,18 @@ class JakAndDaxterReplClient: self.log_error(logger, f"Unable to receive {orb_amount} Precursor Orbs!") return ok + async def receive_trap(self, ap_id: int) -> bool: + trap_id = jak1_max - ap_id + ok = await self.send_form("(send-event " + "*target* \'get-archipelago " + "(pickup-type ap-trap) " + "(the float " + str(trap_id) + "))") + if ok: + logger.debug(f"Received a {item_table[ap_id]}!") + else: + self.log_error(logger, f"Unable to receive a {item_table[ap_id]}!") + return ok + # Green eco pills are our filler item. Use the get-pickup event instead to handle being full health. async def receive_green_eco(self) -> bool: ok = await self.send_form("(send-event *target* \'get-pickup (pickup-type eco-pill) (the float 1))") @@ -408,33 +439,48 @@ class JakAndDaxterReplClient: return True - # OpenGOAL has a limit of 8 parameters per function. We've already hit this limit. We may have to split these - # options into two groups, both of which required to be sent successfully, in the future. - # TODO - Alternatively, define a new datatype in OpenGOAL that holds all these options, instantiate the type here, - # and rewrite the ap-setup-options! function to take that instance as input. + # OpenGOAL has a limit of 8 parameters per function. We've already hit this limit. So, define a new datatype + # in OpenGOAL that holds all these options, instantiate the type here, and have ap-setup-options! function take + # that instance as input. async def setup_options(self, os_option: int, os_bundle: int, fc_count: int, mp_count: int, lt_count: int, ct_amount: int, - ot_amount: int, goal_id: int) -> bool: - ok = await self.send_form(f"(ap-setup-options! " - f"(the uint {os_option}) (the uint {os_bundle}) " - f"(the float {fc_count}) (the float {mp_count}) " - f"(the float {lt_count}) (the float {ct_amount}) " - f"(the float {ot_amount}) (the uint {goal_id}))") + ot_amount: int, trap_time: int, + goal_id: int, slot_name: str, + slot_seed: str) -> bool: + sanitized_name = self.sanitize_file_text(slot_name) + sanitized_seed = self.sanitize_file_text(slot_seed) + + # I didn't want to have to do this with floats but GOAL's compile-time vs runtime types leave me no choice. + ok = await self.send_form(f"(ap-setup-options! (new 'static 'ap-seed-options " + f":orbsanity-option {os_option} " + f":orbsanity-bundle {os_bundle} " + f":fire-canyon-unlock {fc_count}.0 " + f":mountain-pass-unlock {mp_count}.0 " + f":lava-tube-unlock {lt_count}.0 " + f":citizen-orb-amount {ct_amount}.0 " + f":oracle-orb-amount {ot_amount}.0 " + f":trap-duration {trap_time}.0 " + f":completion-goal {goal_id} " + f":slot-name {sanitized_name} " + f":slot-seed {sanitized_seed} ))") message = (f"Setting options: \n" f" Orbsanity Option {os_option}, Orbsanity Bundle {os_bundle}, \n" f" FC Cell Count {fc_count}, MP Cell Count {mp_count}, \n" f" LT Cell Count {lt_count}, Citizen Orb Amt {ct_amount}, \n" - f" Oracle Orb Amt {ot_amount}, Completion GOAL {goal_id}... ") + f" Oracle Orb Amt {ot_amount}, Trap Duration {trap_time}, \n" + f" Completion GOAL {goal_id}, Slot Name {sanitized_name}, \n" + f" Slot Seed {sanitized_seed}... ") if ok: logger.debug(message + "Success!") - status = 1 else: self.log_error(logger, message + "Failed!") - status = 2 - ok = await self.send_form(f"(ap-set-connection-status! (the uint {status}))") + return ok + + async def send_connection_status(self, status: str) -> bool: + ok = await self.send_form(f"(ap-set-connection-status! (connection-status {status}))") if ok: logger.debug(f"Connection Status {status} set!") else: diff --git a/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md index 345a41efb4..c7a44c90ab 100644 --- a/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md +++ b/worlds/jakanddaxter/docs/en_Jak and Daxter The Precursor Legacy.md @@ -15,7 +15,7 @@ At this time, there are several caveats and restrictions: The game now contains the following Location checks: - All 101 Power Cells - All 112 Scout Flies -- All the Orb Caches that are not in Gol and Maia's Citadel (a total of 11) +- All 14 Orb Caches (collect every orb in the cache and let it close) These may contain Items for different games, as well as different Items from within Jak and Daxter. Additionally, several special checks and corresponding items have been added that are required to complete the game. @@ -144,6 +144,35 @@ There are several options to change the difficulty of this challenge. - For example, if your bundle size is 20 orbs, you will add 100 items to the pool. If your bundle size is 250 orbs, you will add 8 items to the pool. +## What do Traps do? +When creating your player YAML, you can choose to replace some of the game's extraneous Power Cells and Precursor Orbs +with traps. You can choose which traps you want to generate in your seed and how long they last. A random assortment +will then be chosen to populate the item pool. + +When you receive one, you will hear a buzzer and some kind of negative effect will occur in game. These effects may be +challenging, maddening, or entertaining. When the trap duration ends, the game should return to its previous state. +Multiple traps can be active at the same time, and they may interact with each other in strange ways. If they become +too frustrating, you can lower their duration by navigating to `Options`, then `Archipelago Options`, then +`Seed Options`, then `Trap Duration`. Lowering this number to zero will disable traps entirely. + +## What kind of Traps are there? +| Trap Name | Effect | +|-----------------|--------------------------------------------------------------------------------| +| Trip Trap | Jak trips and falls | +| Slippery Trap | The world gains the physical properties of Snowy Mountain's ice lake | +| Gravity Trap | Jak falls to the ground faster and takes fall damage more easily | +| Camera Trap | The camera remains fixed in place no matter how far away Jak moves | +| Darkness Trap | The world gains the lighting properties of Dark Cave | +| Earthquake Trap | The world and camera shake | +| Teleport Trap | Jak immediately teleports to Samos's Hut | +| Despair Trap | The Warrior sobs profusely | +| Pacifism Trap | Jak's attacks have no effect on enemies, crates, or buttons | +| Ecoless Trap | Jak's eco is drained and he cannot collect new eco | +| Health Trap | Jak's health is set to 0 - not dead yet, but he will die to any attack or bonk | +| Ledge Trap | Jak cannot grab onto ledges | +| Zoomer Trap | Jak mounts an invisible zoomer (model loads properly depending on level) | +| Mirror Trap | The world is mirrored | + ## I got soft-locked and can't leave, how do I get out of here? Open the game's menu, navigate to `Options`, then `Archipelago Options`, then `Warp To Home`. Selecting this option will ask if you want to be teleported to Geyser Rock. From there, you can teleport back diff --git a/worlds/jakanddaxter/docs/setup_en.md b/worlds/jakanddaxter/docs/setup_en.md index 7d39e8deb5..df5b8e7fb9 100644 --- a/worlds/jakanddaxter/docs/setup_en.md +++ b/worlds/jakanddaxter/docs/setup_en.md @@ -77,26 +77,29 @@ If you are in the middle of an async game, and you do not want to update the mod - Run the Archipelago Launcher. - From the right-most list, find and click `Jak and Daxter Client`. - 3 new windows should appear: - - The OpenGOAL compiler will launch and compile the game. They should take about 30 seconds to compile. - - You should hear a musical cue to indicate the compilation was a success. If you do not, see the Troubleshooting section. - - The game window itself will launch, and Jak will be standing outside Samos's Hut. - - Once compilation is complete, the title intro sequence will start. - - Finally, the Archipelago text client will open. - - If you see `The REPL is ready!` and `The Memory Reader is ready!` then that should indicate a successful startup. -- You can *minimize* the Compiler window, **BUT DO NOT CLOSE IT.** It is required for Archipelago and the game to communicate with each other. -- Use the text client to connect to the Archipelago server while on the title screen. This will communicate your current settings to the game. - - Once you see `AP CONNECTED!` on the title screen, you should be ready. -- Start a new game in the title screen, and play through the cutscenes. -- Once you reach Geyser Rock, you can start the game! + - The OpenGOAL compiler will launch and compile the game. They should take about 30 seconds to compile. + - You should hear a musical cue to indicate the compilation was a success. If you do not, see the Troubleshooting section. + - You can **MINIMIZE** the Compiler window, **BUT DO NOT CLOSE IT.** It is required for Archipelago and the game to communicate with each other. + - The game window itself will launch, and Jak will be standing outside Samos's Hut. + - Once compilation is complete, the title sequence will start. + - Finally, the Archipelago text client will open. + - If you see **BOTH** `The REPL is ready!` and `The Memory Reader is ready!` then that should indicate a successful startup. If you do not, see the Troubleshooting section. +- Once you see `CONNECT TO ARCHIPELAGO NOW` on the title screen, use the text client to connect to the Archipelago server. This will communicate your current settings and slot info to the game. +- If you see `RECEIVING ITEMS, PLEASE WAIT...`, the game is busy receiving items from your starting inventory, assuming you have some. +- Once you see `READY! PRESS START TO CONTINUE` on the title screen, you can press Start. +- Choose `New Game`, choose a save file, and play through the opening cutscenes. +- Once you reach Geyser Rock, the game has begun! - You can leave Geyser Rock immediately if you so choose - just step on the warp gate button. ### Returning / Async Game +The same steps as New Game apply, with some exceptions: -- The same steps as New Game apply, with some exceptions: - - Connect to the Archipelago server **BEFORE** you load your save file. This is to allow AP to give the game your current settings and all the items you had previously. +- Once you reach the title screen, connect to the Archipelago server **BEFORE** you load your save file. + - This is to allow AP to give the game your current settings and all the items you had previously. - **THESE SETTINGS AFFECT LOADING AND SAVING OF SAVE FILES, SO IT IS IMPORTANT TO DO THIS FIRST.** - - Once you see `AP CONNECTED!` on the title screen, you should be ready. - - Then, instead of choosing `New Game` in the title menu, choose `Load Game`, then choose the save file **CORRESPONDING TO YOUR CURRENT ARCHIPELAGO CONNECTION.** +- Once you see `READY! PRESS START TO CONTINUE` on the title screen, you can press Start. +- Instead of choosing `New Game` in the title menu, choose `Load Game`, then choose the save file **THAT HAS YOUR CURRENT SLOT NAME.** + - To help you find the correct save file, highlighting a save will show you that save's slot name and the first 8 digits of the multiworld seed number. ## Troubleshooting @@ -169,4 +172,6 @@ If the client cannot open a REPL connection to the game, you may need to ensure - The Compiler console window is orphaned once you close the game - you will have to kill it manually when you stop playing. - The console windows cannot be run as background processes due to how the REPL works, so the best we can do is minimize them. - Orbsanity checks may show up out of order in the text client. -- Large item releases may take up to several minutes for the game to process them all. +- Large item releases may take up to several minutes for the game to process them all. Item Messages will usually take longer to appear than Items themselves. +- In Lost Precursor City, if you die in the Color Platforms room, the game may crash after you respawn. The cause is unknown. +- Darkness Trap may cause some visual glitches on certain levels. This is temporary, and terrain and object collision are unaffected. \ No newline at end of file diff --git a/worlds/jakanddaxter/locs/OrbCacheLocations.py b/worlds/jakanddaxter/locs/OrbCacheLocations.py index 984d8e7c28..3d9d4fa0cc 100644 --- a/worlds/jakanddaxter/locs/OrbCacheLocations.py +++ b/worlds/jakanddaxter/locs/OrbCacheLocations.py @@ -46,7 +46,7 @@ loc_orbCacheTable = { 23348: "Orb Cache in Snowy Fort (1)", 23349: "Orb Cache in Snowy Fort (2)", 23350: "Orb Cache in Snowy Fort (3)", - # 24038: "Orb Cache at End of Blast Furnace", # TODO - We didn't need all of the orb caches for move rando. - # 24039: "Orb Cache at End of Launch Pad Room", # In future, could add/fill these with filler items? - # 24040: "Orb Cache at Start of Launch Pad Room", + 24038: "Orb Cache at End of Blast Furnace", + 24039: "Orb Cache at End of Launch Pad Room", + 24040: "Orb Cache at Start of Launch Pad Room", } diff --git a/worlds/jakanddaxter/regs/GolAndMaiasCitadelRegions.py b/worlds/jakanddaxter/regs/GolAndMaiasCitadelRegions.py index 7980a964d4..1ef2b4e0e0 100644 --- a/worlds/jakanddaxter/regs/GolAndMaiasCitadelRegions.py +++ b/worlds/jakanddaxter/regs/GolAndMaiasCitadelRegions.py @@ -38,11 +38,13 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte jump_pad_room.add_fly_locations([131163]) # Blue eco vent is right next to it. jump_pad_room.add_fly_locations([65627], access_rule=lambda state: can_free_scout_flies(state, player) and can_jump_farther(state, player)) + jump_pad_room.add_cache_locations([24039, 24040]) # First, blue eco vent, second, blue eco cluster near sage. blast_furnace = JakAndDaxterRegion("Blast Furnace", player, multiworld, level_name, 39) blast_furnace.add_cell_locations([71], access_rule=lambda state: can_fight(state, player)) blast_furnace.add_special_locations([71], access_rule=lambda state: can_fight(state, player)) blast_furnace.add_fly_locations([393307]) # Blue eco vent nearby. + blast_furnace.add_cache_locations([24038]) # Blue eco cluster near sage. bunny_room = JakAndDaxterRegion("Bunny Chamber", player, multiworld, level_name, 45) bunny_room.add_cell_locations([72], access_rule=lambda state: can_fight(state, player)) diff --git a/worlds/jakanddaxter/regs/RegionBase.py b/worlds/jakanddaxter/regs/RegionBase.py index 1f2747e1e1..7edaaa593c 100644 --- a/worlds/jakanddaxter/regs/RegionBase.py +++ b/worlds/jakanddaxter/regs/RegionBase.py @@ -19,6 +19,7 @@ class JakAndDaxterRegion(Region): game: str = jak1_name level_name: str orb_count: int + location_count: int = 0 def __init__(self, name: str, player: int, multiworld: MultiWorld, level_name: str = "", orb_count: int = 0): formatted_name = f"{level_name} {name}".strip() @@ -33,7 +34,7 @@ class JakAndDaxterRegion(Region): """ for loc in locations: ap_id = Cells.to_ap_id(loc) - self.add_jak_locations(ap_id, location_table[ap_id], access_rule) + self.add_jak_location(ap_id, location_table[ap_id], access_rule) def add_fly_locations(self, locations: Iterable[int], access_rule: CollectionRule | None = None) -> None: """ @@ -42,7 +43,7 @@ class JakAndDaxterRegion(Region): """ for loc in locations: ap_id = Scouts.to_ap_id(loc) - self.add_jak_locations(ap_id, location_table[ap_id], access_rule) + self.add_jak_location(ap_id, location_table[ap_id], access_rule) def add_special_locations(self, locations: Iterable[int], access_rule: CollectionRule | None = None) -> None: """ @@ -53,7 +54,7 @@ class JakAndDaxterRegion(Region): """ for loc in locations: ap_id = Specials.to_ap_id(loc) - self.add_jak_locations(ap_id, location_table[ap_id], access_rule) + self.add_jak_location(ap_id, location_table[ap_id], access_rule) def add_cache_locations(self, locations: Iterable[int], access_rule: CollectionRule | None = None) -> None: """ @@ -62,7 +63,7 @@ class JakAndDaxterRegion(Region): """ for loc in locations: ap_id = Caches.to_ap_id(loc) - self.add_jak_locations(ap_id, location_table[ap_id], access_rule) + self.add_jak_location(ap_id, location_table[ap_id], access_rule) def add_orb_locations(self, level_index: int, bundle_index: int, access_rule: CollectionRule | None = None) -> None: """ @@ -77,8 +78,9 @@ class JakAndDaxterRegion(Region): if access_rule: location.access_rule = access_rule self.locations.append(location) + self.location_count += 1 - def add_jak_locations(self, ap_id: int, name: str, access_rule: CollectionRule | None = None) -> None: + def add_jak_location(self, ap_id: int, name: str, access_rule: CollectionRule | None = None) -> None: """ Helper function to add Locations. Not to be used directly. """ @@ -86,3 +88,4 @@ class JakAndDaxterRegion(Region): if access_rule: location.access_rule = access_rule self.locations.append(location) + self.location_count += 1 diff --git a/worlds/jakanddaxter/regs/SentinelBeachRegions.py b/worlds/jakanddaxter/regs/SentinelBeachRegions.py index 27d2e6e8c4..8f4cc63665 100644 --- a/worlds/jakanddaxter/regs/SentinelBeachRegions.py +++ b/worlds/jakanddaxter/regs/SentinelBeachRegions.py @@ -1,3 +1,4 @@ +from BaseClasses import CollectionState from .RegionBase import JakAndDaxterRegion from ..Options import EnableOrbsanity from .. import JakAndDaxterWorld @@ -52,17 +53,22 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte main_area.connect(flut_flut_egg) # Run and jump. main_area.connect(eco_harvesters) # Run. - # You don't need any kind of uppercut to reach this place, just a high jump from a convenient nearby ledge. + # We need a helper function for the uppercut logs. + def can_uppercut_and_jump_logs(state: CollectionState, p: int) -> bool: + return (state.has_any({"Double Jump", "Jump Kick"}, p) + and (state.has_all({"Crouch", "Crouch Uppercut"}, p) + or state.has_all({"Punch", "Punch Uppercut"}, p))) + + # If you have double jump or crouch jump, you don't need the logs to reach this place. main_area.connect(green_ridge, rule=lambda state: state.has("Double Jump", player) - or state.has_all({"Crouch", "Crouch Jump"}, player)) + or state.has_all({"Crouch", "Crouch Jump"}, player) + or can_uppercut_and_jump_logs(state, player)) - # Can either uppercut the log and jump from it, or use the blue eco jump pad. + # If you have the blue eco jump pad, you don't need the logs to reach this place. main_area.connect(blue_ridge, rule=lambda state: state.has("Blue Eco Switch", player) - or (state.has("Double Jump", player) - and (state.has_all({"Crouch", "Crouch Uppercut"}, player) - or state.has_all({"Punch", "Punch Uppercut"}, player)))) + or can_uppercut_and_jump_logs(state, player)) main_area.connect(cannon_tower, rule=lambda state: state.has("Blue Eco Switch", player)) diff --git a/worlds/jakanddaxter/regs/SnowyMountainRegions.py b/worlds/jakanddaxter/regs/SnowyMountainRegions.py index 226f5e1dc0..8028da0ca9 100644 --- a/worlds/jakanddaxter/regs/SnowyMountainRegions.py +++ b/worlds/jakanddaxter/regs/SnowyMountainRegions.py @@ -12,19 +12,13 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte player = world.player # We need a few helper functions. - def can_cross_main_gap(state: CollectionState, p: int) -> bool: + def can_cross_long_gap(state: CollectionState, p: int) -> bool: return (state.has_all({"Roll", "Roll Jump"}, p) or state.has_all({"Double Jump", "Jump Kick"}, p)) - def can_cross_frozen_cave(state: CollectionState, p: int) -> bool: - return (state.has("Jump Kick", p) - and (state.has("Double Jump", p) - or state.has_all({"Roll", "Roll Jump"}, p))) - def can_jump_blockers(state: CollectionState, p: int) -> bool: - return (state.has_any({"Double Jump", "Jump Dive"}, p) + return (state.has_any({"Double Jump", "Jump Kick"}, p) or state.has_all({"Crouch", "Crouch Jump"}, p) - or state.has_all({"Crouch", "Crouch Uppercut"}, p) or state.has_all({"Punch", "Punch Uppercut"}, p)) main_area = JakAndDaxterRegion("Main Area", player, multiworld, level_name, 0) @@ -40,7 +34,7 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte # Troop in fort_exterior: cross main_area and fort_exterior. glacier_lurkers.add_cell_locations([61], access_rule=lambda state: can_fight(state, player) - and can_cross_main_gap(state, player)) + and can_cross_long_gap(state, player)) # Second, a virtual region for the precursor blockers. Unlike the others, this contains orbs: # the total number of orbs that sit on top of the blockers. Yes, there are only 8. @@ -53,17 +47,20 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte # 3 in bunny_cave_start blockers.add_cell_locations([66], access_rule=lambda state: can_fight(state, player) - and can_cross_main_gap(state, player)) + and can_cross_long_gap(state, player)) snowball_canyon = JakAndDaxterRegion("Snowball Canyon", player, multiworld, level_name, 28) + # The scout fly box *can* be broken without YES, so leave it in this region. frozen_box_cave = JakAndDaxterRegion("Frozen Box Cave", player, multiworld, level_name, 12) - frozen_box_cave.add_cell_locations([67], access_rule=lambda state: state.has("Yellow Eco Switch", player)) frozen_box_cave.add_fly_locations([327745], access_rule=lambda state: state.has("Yellow Eco Switch", player) or can_free_scout_flies(state, player)) + # This region has crates that can *only* be broken with YES. frozen_box_cave_crates = JakAndDaxterRegion("Frozen Box Cave Orb Crates", player, multiworld, level_name, 8) + frozen_box_cave_crates.add_cell_locations([67], access_rule=lambda state: + state.has("Yellow Eco Switch", player)) # Include 6 orbs on the twin elevator ice ramp. ice_skating_rink = JakAndDaxterRegion("Ice Skating Rink", player, multiworld, level_name, 20) @@ -111,32 +108,31 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte main_area.connect(glacier_lurkers, rule=lambda state: can_fight(state, player)) # Yes, the only way into the rest of the level requires advanced movement. - main_area.connect(snowball_canyon, rule=lambda state: can_cross_main_gap(state, player)) + main_area.connect(snowball_canyon, rule=lambda state: can_cross_long_gap(state, player)) snowball_canyon.connect(main_area) # But you can just jump down and run up the ramp. snowball_canyon.connect(bunny_cave_start) # Jump down from the glacier troop cliff. snowball_canyon.connect(fort_exterior) # Jump down, to the left of frozen box cave. snowball_canyon.connect(frozen_box_cave, rule=lambda state: # More advanced movement. - can_cross_frozen_cave(state, player)) + can_cross_long_gap(state, player)) frozen_box_cave.connect(snowball_canyon, rule=lambda state: # Same movement to go back. - can_cross_frozen_cave(state, player)) - frozen_box_cave.connect(frozen_box_cave_crates, rule=lambda state: # Same movement to get these crates. - state.has("Yellow Eco Switch", player) # Plus YES. - and can_cross_frozen_cave(state, player)) + can_cross_long_gap(state, player)) + frozen_box_cave.connect(frozen_box_cave_crates, rule=lambda state: # YES to get these crates. + state.has("Yellow Eco Switch", player)) frozen_box_cave.connect(ice_skating_rink, rule=lambda state: # Same movement to go forward. - can_cross_frozen_cave(state, player)) + can_cross_long_gap(state, player)) frozen_box_cave_crates.connect(frozen_box_cave) # Semi-virtual region, no moves req'd. ice_skating_rink.connect(frozen_box_cave, rule=lambda state: # Same movement to go back. - can_cross_frozen_cave(state, player)) + can_cross_long_gap(state, player)) ice_skating_rink.connect(flut_flut_course, rule=lambda state: # Duh. state.has("Flut Flut", player)) ice_skating_rink.connect(fort_exterior) # Just slide down the elevator ramp. fort_exterior.connect(ice_skating_rink, rule=lambda state: # Twin elevators OR scout fly ledge. - can_cross_main_gap(state, player)) # Both doable with main_gap logic. + can_cross_long_gap(state, player)) # Both doable with main_gap logic. fort_exterior.connect(snowball_canyon) # Run across bridge. fort_exterior.connect(fort_interior, rule=lambda state: # Duh. state.has("Snowy Fort Gate", player)) @@ -151,7 +147,7 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte state.has("Double Jump", player) or state.has_all({"Crouch", "Crouch Jump"}, player)) fort_interior.connect(fort_interior_course_end, rule=lambda state: # Just need a little distance. - state.has("Double Jump", player) + state.has_any({"Double Jump", "Jump Kick"}, player) or state.has_all({"Punch", "Punch Uppercut"}, player)) flut_flut_course.connect(fort_exterior) # Ride the elevator. diff --git a/worlds/jakanddaxter/regs/SpiderCaveRegions.py b/worlds/jakanddaxter/regs/SpiderCaveRegions.py index 554991a764..edab96258a 100644 --- a/worlds/jakanddaxter/regs/SpiderCaveRegions.py +++ b/worlds/jakanddaxter/regs/SpiderCaveRegions.py @@ -27,15 +27,8 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte and state.has_all({"Roll", "Roll Jump"}, player)) dark_cave = JakAndDaxterRegion("Dark Cave", player, multiworld, level_name, 5) - dark_cave.add_cell_locations([80], access_rule=lambda state: - can_fight(state, player) - and (state.has("Double Jump", player) - or state.has_all({"Crouch", "Crouch Jump"}, player))) - dark_cave.add_fly_locations([262229], access_rule=lambda state: - can_fight(state, player) - and can_free_scout_flies(state, player) - and (state.has("Double Jump", player) - or state.has_all({"Crouch", "Crouch Jump"}, player))) + dark_cave.add_cell_locations([80]) + dark_cave.add_fly_locations([262229], access_rule=lambda state: can_free_scout_flies(state, player)) robot_cave = JakAndDaxterRegion("Robot Cave", player, multiworld, level_name, 0) @@ -64,7 +57,10 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte main_area.connect(dark_crystals) main_area.connect(robot_cave) - main_area.connect(dark_cave, rule=lambda state: can_fight(state, player)) + main_area.connect(dark_cave, rule=lambda state: + can_fight(state, player) + and (state.has("Double Jump", player) + or state.has_all({"Crouch", "Crouch Jump"}, player))) robot_cave.connect(main_area) robot_cave.connect(pole_course) # Nothing special required. @@ -75,8 +71,9 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> list[JakAndDaxte scaffolding_level_one.connect(robot_cave) # All scaffolding (level 1+) connects back by jumping down. - # Elevator, but the orbs need double jump. - scaffolding_level_one.connect(scaffolding_level_zero, rule=lambda state: state.has("Double Jump", player)) + # Elevator, but the orbs need double jump or jump kick. + scaffolding_level_one.connect(scaffolding_level_zero, rule=lambda state: + state.has_any({"Double Jump", "Jump Kick"}, player)) # Narrow enough that enemies are unavoidable. scaffolding_level_one.connect(scaffolding_level_two, rule=lambda state: can_fight(state, player)) diff --git a/worlds/jakanddaxter/test/__init__.py b/worlds/jakanddaxter/test/__init__.py index 7c666a97b4..12f6aa377d 100644 --- a/worlds/jakanddaxter/test/__init__.py +++ b/worlds/jakanddaxter/test/__init__.py @@ -12,80 +12,96 @@ class JakAndDaxterTestBase(WorldTestBase): "cells": 4, "flies": 7, "orbs": 50, + "caches": 0, }, "Sandover Village": { "cells": 6, "flies": 7, "orbs": 50, + "caches": 1, }, "Forbidden Jungle": { "cells": 8, "flies": 7, "orbs": 150, + "caches": 1, }, "Sentinel Beach": { "cells": 8, "flies": 7, "orbs": 150, + "caches": 2, }, "Misty Island": { "cells": 8, "flies": 7, "orbs": 150, + "caches": 1, }, "Fire Canyon": { "cells": 2, "flies": 7, "orbs": 50, + "caches": 0, }, "Rock Village": { "cells": 6, "flies": 7, "orbs": 50, + "caches": 1, }, "Precursor Basin": { "cells": 8, "flies": 7, "orbs": 200, + "caches": 0, }, "Lost Precursor City": { "cells": 8, "flies": 7, "orbs": 200, + "caches": 2, }, "Boggy Swamp": { "cells": 8, "flies": 7, "orbs": 200, + "caches": 0, }, "Mountain Pass": { "cells": 4, "flies": 7, "orbs": 50, + "caches": 0, }, "Volcanic Crater": { "cells": 8, "flies": 7, "orbs": 50, + "caches": 0, }, "Spider Cave": { "cells": 8, "flies": 7, "orbs": 200, + "caches": 0, }, "Snowy Mountain": { "cells": 8, "flies": 7, "orbs": 200, + "caches": 3, }, "Lava Tube": { "cells": 2, "flies": 7, "orbs": 50, + "caches": 0, }, "Gol and Maia's Citadel": { "cells": 5, "flies": 7, "orbs": 200, + "caches": 3, }, } diff --git a/worlds/jakanddaxter/test/test_locations.py b/worlds/jakanddaxter/test/test_locations.py index 46c5aa5887..7f69fcf005 100644 --- a/worlds/jakanddaxter/test/test_locations.py +++ b/worlds/jakanddaxter/test/test_locations.py @@ -4,16 +4,21 @@ from ..test import JakAndDaxterTestBase from ..GameID import jak1_id from ..regs.RegionBase import JakAndDaxterRegion from ..locs import (ScoutLocations as Scouts, - SpecialLocations as Specials) + SpecialLocations as Specials, + OrbCacheLocations as Caches, + OrbLocations as Orbs) class LocationsTest(JakAndDaxterTestBase): + def get_regions(self): + return [typing.cast(JakAndDaxterRegion, reg) for reg in self.multiworld.get_regions(self.player)] + def test_count_cells(self): - regions = [typing.cast(JakAndDaxterRegion, reg) for reg in self.multiworld.get_regions(self.player)] + for level in self.level_info: cell_count = 0 - sublevels = [reg for reg in regions if reg.level_name == level] + sublevels = [reg for reg in self.get_regions() if reg.level_name == level] for sl in sublevels: for loc in sl.locations: if loc.address in range(jak1_id, jak1_id + Scouts.fly_offset): @@ -21,10 +26,9 @@ class LocationsTest(JakAndDaxterTestBase): self.assertEqual(self.level_info[level]["cells"] - 1, cell_count, level) # Don't count the Free 7 Cells. def test_count_flies(self): - regions = [typing.cast(JakAndDaxterRegion, reg) for reg in self.multiworld.get_regions(self.player)] for level in self.level_info: fly_count = 0 - sublevels = [reg for reg in regions if reg.level_name == level] + sublevels = [reg for reg in self.get_regions() if reg.level_name == level] for sl in sublevels: for loc in sl.locations: if loc.address in range(jak1_id + Scouts.fly_offset, jak1_id + Specials.special_offset): @@ -32,8 +36,17 @@ class LocationsTest(JakAndDaxterTestBase): self.assertEqual(self.level_info[level]["flies"], fly_count, level) def test_count_orbs(self): - regions = [typing.cast(JakAndDaxterRegion, reg) for reg in self.multiworld.get_regions(self.player)] for level in self.level_info: - sublevels = [reg for reg in regions if reg.level_name == level] + sublevels = [reg for reg in self.get_regions() if reg.level_name == level] orb_count = sum([reg.orb_count for reg in sublevels]) self.assertEqual(self.level_info[level]["orbs"], orb_count, level) + + def test_count_caches(self): + for level in self.level_info: + cache_count = 0 + sublevels = [reg for reg in self.get_regions() if reg.level_name == level] + for sl in sublevels: + for loc in sl.locations: + if loc.address in range(jak1_id + Caches.orb_cache_offset, jak1_id + Orbs.orb_offset): + cache_count += 1 + self.assertEqual(self.level_info[level]["caches"], cache_count, level) diff --git a/worlds/jakanddaxter/test/test_moverando.py b/worlds/jakanddaxter/test/test_moverando.py index b0dcd13636..10c8a0ca98 100644 --- a/worlds/jakanddaxter/test/test_moverando.py +++ b/worlds/jakanddaxter/test/test_moverando.py @@ -10,9 +10,23 @@ class MoveRandoTest(JakAndDaxterTestBase): def test_move_items_in_pool(self): for move in move_item_table: self.assertIn(move_item_table[move], {item.name for item in self.multiworld.itempool}) + self.assertNotIn(move_item_table[move], + {item.name for item in self.multiworld.precollected_items[self.player]}) def test_cannot_reach_without_move(self): self.assertAccessDependency( ["GR: Climb Up The Cliff"], [["Double Jump"], ["Crouch"]], only_check_listed=True) + + +class NoMoveRandoTest(JakAndDaxterTestBase): + options = { + "enable_move_randomizer": False + } + + def test_move_items_in_inventory(self): + for move in move_item_table: + self.assertNotIn(move_item_table[move], {item.name for item in self.multiworld.itempool}) + self.assertIn(move_item_table[move], + {item.name for item in self.multiworld.precollected_items[self.player]}) \ No newline at end of file diff --git a/worlds/jakanddaxter/test/test_traps.py b/worlds/jakanddaxter/test/test_traps.py new file mode 100644 index 0000000000..af6e1361a2 --- /dev/null +++ b/worlds/jakanddaxter/test/test_traps.py @@ -0,0 +1,80 @@ +from BaseClasses import ItemClassification +from ..test import JakAndDaxterTestBase + + +class NoTrapsTest(JakAndDaxterTestBase): + options = { + "filler_power_cells_replaced_with_traps": 0, + "filler_orb_bundles_replaced_with_traps": 0, + "chosen_traps": ["Trip Trap"] + } + + def test_trap_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Trip Trap" + and item.classification == ItemClassification.trap]) + self.assertEqual(0, count) + + def test_prog_power_cells_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Power Cell" + and item.classification == ItemClassification.progression_skip_balancing]) + self.assertEqual(72, count) + + def test_fill_power_cells_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Power Cell" + and item.classification == ItemClassification.filler]) + self.assertEqual(29, count) + + +class SomeTrapsTest(JakAndDaxterTestBase): + options = { + "filler_power_cells_replaced_with_traps": 10, + "filler_orb_bundles_replaced_with_traps": 10, + "chosen_traps": ["Trip Trap"] + } + + def test_trap_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Trip Trap" + and item.classification == ItemClassification.trap]) + self.assertEqual(10, count) + + def test_prog_power_cells_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Power Cell" + and item.classification == ItemClassification.progression_skip_balancing]) + self.assertEqual(72, count) + + def test_fill_power_cells_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Power Cell" + and item.classification == ItemClassification.filler]) + self.assertEqual(19, count) + + +class MaximumTrapsTest(JakAndDaxterTestBase): + options = { + "filler_power_cells_replaced_with_traps": 100, + "filler_orb_bundles_replaced_with_traps": 100, + "chosen_traps": ["Trip Trap"] + } + + def test_trap_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Trip Trap" + and item.classification == ItemClassification.trap]) + self.assertEqual(29, count) + + def test_prog_power_cells_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Power Cell" + and item.classification == ItemClassification.progression_skip_balancing]) + self.assertEqual(72, count) + + def test_fill_power_cells_count(self): + count = len([item.name for item in self.multiworld.itempool + if item.name == "Power Cell" + and item.classification == ItemClassification.filler]) + self.assertEqual(0, count)