mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-24 21:23:19 -07:00
Traps 2 (#70)
* Add trap items, relevant options, and citadel orb caches. * Update REPL to send traps to game. * Fix item counter. * Allow player to select which traps to use. * Fix host.yaml doc strings, ap-setup-options typing, bump memory version to 5. * Alter some trap names. * Update world doc. * Add health trap. * Added 3 more trap types. * Protect against empty trap list. * Reword traps paragraph in world doc. * Another update to trap paragraph. * Concisify trap option docstring. * Timestamp on game log file. * Update client to handle waiting on title screen. * Send slot name and seed to game. * Use self.random instead. * Update setup doc for new title screen. * Quick clarification of orb caches in world doc. * Sanitize slot info earlier. * Added to and improved unit tests. * Light cleanup on world. * Optimizations to movement rules, docs: known issues update.
This commit is contained in:
committed by
GitHub
parent
c1cceb5582
commit
263311d641
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]})
|
||||
80
worlds/jakanddaxter/test/test_traps.py
Normal file
80
worlds/jakanddaxter/test/test_traps.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user