* 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:
massimilianodelliubaldini
2025-02-15 23:35:30 -05:00
committed by GitHub
parent c1cceb5582
commit 263311d641
18 changed files with 500 additions and 140 deletions

View File

@@ -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(

View File

@@ -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.
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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",
}

View File

@@ -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))

View File

@@ -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

View File

@@ -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))

View File

@@ -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.

View File

@@ -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))

View File

@@ -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,
},
}

View File

@@ -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)

View File

@@ -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]})

View 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)