Finishing Touches (#36)

* Set up connector level thresholds, completion goal choices.

* Send AP sender/recipient info to game via client.

* Slight refactors.

* Refactor option checking, add DataStorage handling of traded orbs.

* Update instructions to change order of load/connect.

* Add Option check to ensure enough Locations exist for Cell Count thresholds. Fix Final Door region.

* Need some height move to get LPC sunken chamber cell.
This commit is contained in:
massimilianodelliubaldini
2024-07-23 19:32:15 -04:00
committed by GitHub
parent f7b688de38
commit 35bf07806f
25 changed files with 407 additions and 95 deletions

View File

@@ -8,8 +8,9 @@ import pymem
from pymem.exception import ProcessNotFound, ProcessError
import Utils
from NetUtils import ClientStatus
from NetUtils import ClientStatus, NetworkItem
from CommonClient import ClientCommandProcessor, CommonContext, logger, server_loop, gui_enabled
from .JakAndDaxterOptions import EnableOrbsanity
from .GameID import jak1_name
from .client.ReplClient import JakAndDaxterReplClient
@@ -84,8 +85,8 @@ class JakAndDaxterContext(CommonContext):
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
self.repl = JakAndDaxterReplClient()
self.memr = JakAndDaxterMemoryReader()
# self.memr.load_data()
# self.repl.load_data()
# self.memr.load_data()
super().__init__(server_address, password)
def run_gui(self):
@@ -110,19 +111,71 @@ class JakAndDaxterContext(CommonContext):
if cmd == "Connected":
slot_data = args["slot_data"]
if slot_data["enable_orbsanity"] == 1:
self.repl.setup_orbsanity(slot_data["enable_orbsanity"], slot_data["level_orbsanity_bundle_size"])
elif slot_data["enable_orbsanity"] == 2:
self.repl.setup_orbsanity(slot_data["enable_orbsanity"], slot_data["global_orbsanity_bundle_size"])
orbsanity_option = slot_data["enable_orbsanity"]
if orbsanity_option == EnableOrbsanity.option_per_level:
orbsanity_bundle = slot_data["level_orbsanity_bundle_size"]
elif orbsanity_option == EnableOrbsanity.option_global:
orbsanity_bundle = slot_data["global_orbsanity_bundle_size"]
else:
self.repl.setup_orbsanity(slot_data["enable_orbsanity"], 1)
orbsanity_bundle = 1
self.repl.setup_options(orbsanity_option,
orbsanity_bundle,
slot_data["fire_canyon_cell_count"],
slot_data["mountain_pass_cell_count"],
slot_data["lava_tube_cell_count"],
slot_data["completion_condition"])
# 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,
# while "ReceivedItems" will tell us the orbs we gained. This will give us the correct balance.
if orbsanity_option in [EnableOrbsanity.option_per_level, EnableOrbsanity.option_global]:
async def get_orb_balance():
await self.send_msgs([{"cmd": "Get",
"keys": [f"jakanddaxter_{self.auth}_orbs_paid"]
}])
create_task_log_exception(get_orb_balance())
if cmd == "Retrieved":
if f"jakanddaxter_{self.auth}_orbs_paid" in args["keys"]:
orbs_traded = args["keys"][f"jakanddaxter_{self.auth}_orbs_paid"]
self.repl.subtract_traded_orbs(orbs_traded if orbs_traded is not None else 0)
if cmd == "ReceivedItems":
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
self.memr.save_data()
self.repl.save_data()
def on_print_json(self, args: dict) -> None:
if "type" in args and args["type"] in {"ItemSend"}:
item = args["item"]
recipient = args["receiving"]
# Receiving an item from the server.
if self.slot_concerns_self(recipient):
self.repl.my_item_name = self.item_names.lookup_in_game(item.item)
# Did we find it, or did someone else?
if self.slot_concerns_self(item.player):
self.repl.my_item_finder = "MYSELF"
else:
self.repl.my_item_finder = self.player_names[item.player]
# Sending an item to the server.
if self.slot_concerns_self(item.player):
self.repl.their_item_name = self.item_names.lookup_in_slot(item.item, recipient)
# Does it belong to us, or to someone else?
if self.slot_concerns_self(recipient):
self.repl.their_item_owner = "MYSELF"
else:
self.repl.their_item_owner = self.player_names[recipient]
# Write to game display.
self.repl.write_game_text()
super(JakAndDaxterContext, self).on_print_json(args)
def on_deathlink(self, data: dict):
if self.memr.deathlink_enabled:
@@ -174,6 +227,18 @@ class JakAndDaxterContext(CommonContext):
def on_orbsanity_check(self):
create_task_log_exception(self.repl_reset_orbsanity())
async def ap_inform_orb_trade(self, orbs_changed: int):
if self.memr.orbsanity_enabled:
await self.send_msgs([{"cmd": "Set",
"key": f"jakanddaxter_{self.auth}_orbs_paid",
"default": 0,
"want_reply": False,
"operations": [{"operation": "add", "value": orbs_changed}]
}])
def on_orb_trade(self, orbs_changed: int):
create_task_log_exception(self.ap_inform_orb_trade(orbs_changed))
async def run_repl_loop(self):
while True:
await self.repl.main_tick()
@@ -185,7 +250,8 @@ class JakAndDaxterContext(CommonContext):
self.on_finish_check,
self.on_deathlink_check,
self.on_deathlink_toggle,
self.on_orbsanity_check)
self.on_orbsanity_check,
self.on_orb_trade)
await asyncio.sleep(0.1)

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from Options import Toggle, PerGameCommonOptions, Choice
from Options import Toggle, PerGameCommonOptions, Choice, Range
class EnableMoveRandomizer(Toggle):
@@ -66,9 +66,50 @@ class PerLevelOrbsanityBundleSize(Choice):
default = 1
class FireCanyonCellCount(Range):
"""Set the number of orbs you need to cross Fire Canyon."""
display_name = "Fire Canyon Cell Count"
range_start = 0
range_end = 100
default = 20
class MountainPassCellCount(Range):
"""Set the number of orbs you need to reach Klaww and cross Mountain Pass."""
display_name = "Mountain Pass Cell Count"
range_start = 0
range_end = 100
default = 45
class LavaTubeCellCount(Range):
"""Set the number of orbs you need to cross Lava Tube."""
display_name = "Lava Tube Cell Count"
range_start = 0
range_end = 100
default = 72
class CompletionCondition(Choice):
"""Set the goal for completing the game."""
display_name = "Completion Condition"
option_cross_fire_canyon = 69
option_cross_mountain_pass = 87
option_cross_lava_tube = 89
option_defeat_dark_eco_plant = 6
option_defeat_klaww = 86
option_defeat_gol_and_maia = 112
option_open_100_cell_door = 116
default = 112
@dataclass
class JakAndDaxterOptions(PerGameCommonOptions):
enable_move_randomizer: EnableMoveRandomizer
enable_orbsanity: EnableOrbsanity
global_orbsanity_bundle_size: GlobalOrbsanityBundleSize
level_orbsanity_bundle_size: PerLevelOrbsanityBundleSize
fire_canyon_cell_count: FireCanyonCellCount
mountain_pass_cell_count: MountainPassCellCount
lava_tube_cell_count: LavaTubeCellCount
completion_condition: CompletionCondition

View File

@@ -1,9 +1,14 @@
from BaseClasses import MultiWorld
from .JakAndDaxterOptions import JakAndDaxterOptions
from .Items import item_table
from BaseClasses import MultiWorld, CollectionState, ItemClassification
from Options import OptionError
from .JakAndDaxterOptions import (JakAndDaxterOptions,
EnableMoveRandomizer,
EnableOrbsanity,
CompletionCondition)
from .Items import (JakAndDaxterItem,
item_table,
move_item_table)
from .Rules import can_reach_orbs
from .locs import (OrbLocations as Orbs,
CellLocations as Cells,
from .locs import (CellLocations as Cells,
ScoutLocations as Scouts)
from .regs.RegionBase import JakAndDaxterRegion
from .regs import (GeyserRockRegions as GeyserRock,
@@ -44,7 +49,7 @@ def create_regions(multiworld: MultiWorld, options: JakAndDaxterOptions, player:
# If Global Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Menu. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 2:
if options.enable_orbsanity == EnableOrbsanity.option_global:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld)
bundle_size = options.global_orbsanity_bundle_size.value
@@ -64,7 +69,7 @@ def create_regions(multiworld: MultiWorld, options: JakAndDaxterOptions, player:
# Build all regions. Include their intra-connecting Rules, their Locations, and their Location access rules.
[gr] = GeyserRock.build_regions("Geyser Rock", multiworld, options, player)
[sv] = SandoverVillage.build_regions("Sandover Village", multiworld, options, player)
[fj] = ForbiddenJungle.build_regions("Forbidden Jungle", multiworld, options, player)
[fj, fjp] = ForbiddenJungle.build_regions("Forbidden Jungle", multiworld, options, player)
[sb] = SentinelBeach.build_regions("Sentinel Beach", multiworld, options, player)
[mi] = MistyIsland.build_regions("Misty Island", multiworld, options, player)
[fc] = FireCanyon.build_regions("Fire Canyon", multiworld, options, player)
@@ -77,7 +82,12 @@ def create_regions(multiworld: MultiWorld, options: JakAndDaxterOptions, player:
[sc] = SpiderCave.build_regions("Spider Cave", multiworld, options, player)
[sm] = SnowyMountain.build_regions("Snowy Mountain", multiworld, options, player)
[lt] = LavaTube.build_regions("Lava Tube", multiworld, options, player)
[gmc, fb] = GolAndMaiasCitadel.build_regions("Gol and Maia's Citadel", multiworld, options, player)
[gmc, fb, fd] = GolAndMaiasCitadel.build_regions("Gol and Maia's Citadel", multiworld, options, player)
# Configurable counts of cells for connector levels.
fc_count = options.fire_canyon_cell_count.value
mp_count = options.mountain_pass_cell_count.value
lt_count = options.lava_tube_cell_count.value
# Define the interconnecting rules.
menu.connect(gr)
@@ -85,17 +95,90 @@ def create_regions(multiworld: MultiWorld, options: JakAndDaxterOptions, player:
sv.connect(fj)
sv.connect(sb)
sv.connect(mi, rule=lambda state: state.has("Fisherman's Boat", player))
sv.connect(fc, rule=lambda state: state.has("Power Cell", player, 20))
sv.connect(fc, rule=lambda state: state.has("Power Cell", player, fc_count)) # Normally 20.
fc.connect(rv)
rv.connect(pb)
rv.connect(lpc)
rvp.connect(bs) # rv->rvp/rvc connections defined internally by RockVillageRegions.
rvc.connect(mp, rule=lambda state: state.has("Power Cell", player, 45))
rvc.connect(mp, rule=lambda state: state.has("Power Cell", player, mp_count)) # Normally 45.
mpr.connect(vc) # mp->mpr connection defined internally by MountainPassRegions.
vc.connect(sc)
vc.connect(sm, rule=lambda state: state.has("Snowy Mountain Gondola", player))
vc.connect(lt, rule=lambda state: state.has("Power Cell", player, 72))
vc.connect(lt, rule=lambda state: state.has("Power Cell", player, lt_count)) # Normally 72.
lt.connect(gmc) # gmc->fb connection defined internally by GolAndMaiasCitadelRegions.
# Finally, set the completion condition.
multiworld.completion_condition[player] = lambda state: state.can_reach(fb, "Region", player)
# Set the completion condition.
if options.completion_condition == CompletionCondition.option_cross_fire_canyon:
multiworld.completion_condition[player] = lambda state: state.can_reach(rv, "Region", player)
elif options.completion_condition == CompletionCondition.option_cross_mountain_pass:
multiworld.completion_condition[player] = lambda state: state.can_reach(vc, "Region", player)
elif options.completion_condition == CompletionCondition.option_cross_lava_tube:
multiworld.completion_condition[player] = lambda state: state.can_reach(gmc, "Region", player)
elif options.completion_condition == CompletionCondition.option_defeat_dark_eco_plant:
multiworld.completion_condition[player] = lambda state: state.can_reach(fjp, "Region", player)
elif options.completion_condition == CompletionCondition.option_defeat_klaww:
multiworld.completion_condition[player] = lambda state: state.can_reach(mp, "Region", player)
elif options.completion_condition == CompletionCondition.option_defeat_gol_and_maia:
multiworld.completion_condition[player] = lambda state: state.can_reach(fb, "Region", player)
elif options.completion_condition == CompletionCondition.option_open_100_cell_door:
multiworld.completion_condition[player] = lambda state: state.can_reach(fd, "Region", player)
# As a final sanity check on these options, verify that we have enough locations to allow us to cross
# the connector levels. E.g. if you set Fire Canyon count to 99, we may not have 99 Locations in hub 1.
verify_connector_level_accessibility(multiworld, options, player)
def verify_connector_level_accessibility(multiworld: MultiWorld, options: JakAndDaxterOptions, player: int):
# Set up a state where we only have the items we need to progress, exactly when we need them, as well as
# any items we would have/get from our other options. The only variable we're actually testing here is the
# number of power cells we need.
state = CollectionState(multiworld)
if options.enable_move_randomizer == EnableMoveRandomizer.option_false:
for move in move_item_table:
state.collect(JakAndDaxterItem(move_item_table[move], ItemClassification.progression, move, player))
thresholds = {
0: {
"option": options.fire_canyon_cell_count,
"required_items": {},
},
1: {
"option": options.mountain_pass_cell_count,
"required_items": {
33: "Warrior's Pontoons",
10945: "Double Jump",
},
},
2: {
"option": options.lava_tube_cell_count,
"required_items": {},
},
}
loc = 0
for k in thresholds:
option = thresholds[k]["option"]
required_items = thresholds[k]["required_items"]
# Given our current state (starting with 0 Power Cells), determine if there are enough
# Locations to fill with the number of Power Cells needed for the next threshold.
locations_available = multiworld.get_reachable_locations(state, player)
if len(locations_available) < option.value:
raise OptionError(f"Settings conflict with {option.display_name}: "
f"not enough potential locations ({len(locations_available)}) "
f"for the required number of power cells ({option.value}).")
# Once we've determined we can pass the current threshold, add what we need to reach the next one.
for _ in range(option.value):
state.collect(JakAndDaxterItem("Power Cell", ItemClassification.progression, loc, player))
loc += 1
for item in required_items:
state.collect(JakAndDaxterItem(required_items[item], ItemClassification.progression, item, player))

View File

@@ -1,7 +1,7 @@
import math
import typing
from BaseClasses import MultiWorld, CollectionState
from .JakAndDaxterOptions import JakAndDaxterOptions
from .JakAndDaxterOptions import JakAndDaxterOptions, EnableOrbsanity
from .Items import orb_item_table
from .locs import CellLocations as Cells
from .Locations import location_table
@@ -16,7 +16,7 @@ def can_reach_orbs(state: CollectionState,
# Global Orbsanity and No Orbsanity both treat orbs as completely interchangeable.
# Per Level Orbsanity needs to know if you can reach orbs *in a particular level.*
if options.enable_orbsanity.value in [0, 2]:
if options.enable_orbsanity != EnableOrbsanity.option_per_level:
return can_reach_orbs_global(state, player, multiworld)
else:
return can_reach_orbs_level(state, player, multiworld, level_name)
@@ -57,10 +57,10 @@ def can_trade(state: CollectionState,
required_orbs: int,
required_previous_trade: int = None) -> bool:
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
bundle_size = options.level_orbsanity_bundle_size.value
return can_trade_orbsanity(state, player, bundle_size, required_orbs, required_previous_trade)
elif options.enable_orbsanity.value == 2:
elif options.enable_orbsanity == EnableOrbsanity.option_global:
bundle_size = options.global_orbsanity_bundle_size.value
return can_trade_orbsanity(state, player, bundle_size, required_orbs, required_previous_trade)
else:

View File

@@ -4,7 +4,7 @@ import settings
from Utils import local_path, visualize_regions
from BaseClasses import Item, ItemClassification, Tutorial
from .GameID import jak1_id, jak1_name, jak1_max
from .JakAndDaxterOptions import JakAndDaxterOptions
from .JakAndDaxterOptions import JakAndDaxterOptions, EnableOrbsanity
from .Locations import JakAndDaxterLocation, location_table
from .Items import JakAndDaxterItem, item_table
from .locs import (CellLocations as Cells,
@@ -93,13 +93,13 @@ class JakAndDaxterWorld(World):
# This will also set Locations, Location access rules, Region access rules, etc.
def create_regions(self) -> None:
create_regions(self.multiworld, self.options, self.player)
# visualize_regions(self.multiworld.get_region("Menu", self.player), "jak.puml")
visualize_regions(self.multiworld.get_region("Menu", self.player), "jakanddaxter.puml")
# Helper function to get the correct orb bundle size.
def get_orb_bundle_size(self) -> int:
if self.options.enable_orbsanity.value == 1:
if self.options.enable_orbsanity == EnableOrbsanity.option_per_level:
return self.options.level_orbsanity_bundle_size.value
elif self.options.enable_orbsanity.value == 2:
elif self.options.enable_orbsanity == EnableOrbsanity.option_global:
return self.options.global_orbsanity_bundle_size.value
else:
return 0
@@ -158,9 +158,9 @@ class JakAndDaxterWorld(World):
# Handle Orbsanity option.
# If it is OFF, don't add any orbs to the item pool.
# If it is ON, only add the orb bundle that matches the choice in options.
# 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.value == 0
and ((self.options.enable_orbsanity == EnableOrbsanity.option_off
or Orbs.to_game_id(item_id) != self.get_orb_bundle_size()))):
continue
@@ -181,4 +181,8 @@ class JakAndDaxterWorld(World):
return self.options.as_dict("enable_move_randomizer",
"enable_orbsanity",
"global_orbsanity_bundle_size",
"level_orbsanity_bundle_size")
"level_orbsanity_bundle_size",
"fire_canyon_cell_count",
"mountain_pass_cell_count",
"lava_tube_cell_count",
"completion_condition")

View File

@@ -17,6 +17,7 @@ from ..locs import (OrbLocations as Orbs,
sizeof_uint64 = 8
sizeof_uint32 = 4
sizeof_uint8 = 1
sizeof_float = 4
# IMPORTANT: OpenGOAL memory structures are particular about the alignment, in memory, of member elements according to
@@ -72,6 +73,19 @@ orbsanity_bundle_offset = offsets.define(sizeof_uint32)
collected_bundle_level_offset = offsets.define(sizeof_uint8)
collected_bundle_count_offset = offsets.define(sizeof_uint32)
# Progression and Completion information.
fire_canyon_unlock_offset = offsets.define(sizeof_float)
mountain_pass_unlock_offset = offsets.define(sizeof_float)
lava_tube_unlock_offset = offsets.define(sizeof_float)
completion_goal_offset = offsets.define(sizeof_uint8)
completed_offset = offsets.define(sizeof_uint8)
# Text to display in the HUD (32 char max per string).
their_item_name_offset = offsets.define(sizeof_uint8, 32)
their_item_owner_offset = offsets.define(sizeof_uint8, 32)
my_item_name_offset = offsets.define(sizeof_uint8, 32)
my_item_finder_offset = offsets.define(sizeof_uint8, 32)
# The End.
end_marker_offset = offsets.define(sizeof_uint8, 4)
@@ -138,6 +152,7 @@ class JakAndDaxterMemoryReader:
# Orbsanity handling
orbsanity_enabled: bool = False
reset_orbsanity: bool = False
orbs_paid: int = 0
def __init__(self, marker: ByteString = b'UnLiStEdStRaTs_JaK1\x00'):
self.marker = marker
@@ -148,7 +163,8 @@ class JakAndDaxterMemoryReader:
finish_callback: Callable,
deathlink_callback: Callable,
deathlink_toggle: Callable,
orbsanity_callback: Callable):
orbsanity_callback: Callable,
paid_orbs_callback: Callable):
if self.initiated_connect:
await self.connect()
self.initiated_connect = False
@@ -171,6 +187,7 @@ class JakAndDaxterMemoryReader:
# Checked Locations in game. Handle the entire outbox every tick until we're up to speed.
if len(self.location_outbox) > self.outbox_index:
location_callback(self.location_outbox)
self.save_data()
self.outbox_index += 1
if self.finished_game:
@@ -186,6 +203,10 @@ class JakAndDaxterMemoryReader:
if self.reset_orbsanity:
orbsanity_callback()
if self.orbs_paid > 0:
paid_orbs_callback(self.orbs_paid)
self.orbs_paid = 0
async def connect(self):
try:
self.gk_process = pymem.Pymem("gk.exe") # The GOAL Kernel
@@ -219,7 +240,7 @@ class JakAndDaxterMemoryReader:
logger.info("Memory Reader Status:")
logger.info(" Game process ID: " + (str(self.gk_process.process_id) if self.gk_process else "None"))
logger.info(" Game state memory address: " + str(self.goal_address))
logger.info(" Last location checked: " + (str(self.location_outbox[self.outbox_index])
logger.info(" Last location checked: " + (str(self.location_outbox[self.outbox_index - 1])
if self.outbox_index else "None"))
def read_memory(self) -> List[int]:
@@ -235,6 +256,16 @@ class JakAndDaxterMemoryReader:
self.location_outbox.append(cell_ap_id)
logger.debug("Checked power cell: " + str(next_cell))
# If orbsanity is ON and next_cell is one of the traders or oracles, then run a callback
# to add their amount to the DataStorage value holding our current orb trade total.
if next_cell in {11, 12, 31, 32, 33, 96, 97, 98, 99}:
self.orbs_paid += 90
logger.debug("Traded 90 orbs!")
if next_cell in {13, 14, 34, 35, 100, 101}:
self.orbs_paid += 120
logger.debug("Traded 120 orbs!")
for k in range(0, next_buzzer_index):
next_buzzer = self.read_goal_address(buzzers_checked_offset + (k * sizeof_uint32), sizeof_uint32)
buzzer_ap_id = Flies.to_ap_id(next_buzzer)
@@ -244,19 +275,10 @@ class JakAndDaxterMemoryReader:
for k in range(0, next_special_index):
next_special = self.read_goal_address(specials_checked_offset + (k * sizeof_uint32), sizeof_uint32)
# 112 is the game-task ID of `finalboss-movies`, which is written to this array when you grab
# the white eco. This is our victory condition, so we need to catch it and act on it.
if next_special == 112 and not self.finished_game:
self.finished_game = True
logger.info("Congratulations! You finished the game!")
else:
# All other special checks handled as normal.
special_ap_id = Specials.to_ap_id(next_special)
if special_ap_id not in self.location_outbox:
self.location_outbox.append(special_ap_id)
logger.debug("Checked special: " + str(next_special))
special_ap_id = Specials.to_ap_id(next_special)
if special_ap_id not in self.location_outbox:
self.location_outbox.append(special_ap_id)
logger.debug("Checked special: " + str(next_special))
died = self.read_goal_address(died_offset, sizeof_uint8)
if died > 0:
@@ -302,6 +324,11 @@ class JakAndDaxterMemoryReader:
# self.reset_orbsanity = True
completed = self.read_goal_address(completed_offset, sizeof_uint8)
if completed > 0 and not self.finished_game:
self.finished_game = True
logger.info("Congratulations! You finished the game!")
except (ProcessError, MemoryReadError, WinAPIError):
logger.error("The gk process has died. Restart the game and run \"/memr connect\" again.")
self.connected = False

View File

@@ -1,7 +1,7 @@
import json
import time
import struct
import typing
from typing import Dict, Callable
import random
from socket import socket, AF_INET, SOCK_STREAM
@@ -33,9 +33,14 @@ class JakAndDaxterReplClient:
gk_process: pymem.process = None
goalc_process: pymem.process = None
item_inbox: typing.Dict[int, NetworkItem] = {}
item_inbox: Dict[int, NetworkItem] = {}
inbox_index = 0
my_item_name: str = None
my_item_finder: str = None
their_item_name: str = None
their_item_owner: str = None
def __init__(self, ip: str = "127.0.0.1", port: int = 8181):
self.ip = ip
self.port = port
@@ -63,6 +68,7 @@ class JakAndDaxterReplClient:
# Receive Items from AP. Handle 1 item per tick.
if len(self.item_inbox) > self.inbox_index:
self.receive_item()
self.save_data()
self.inbox_index += 1
if self.received_deathlink:
@@ -189,6 +195,35 @@ class JakAndDaxterReplClient:
logger.info(" Last item received: " + (str(getattr(self.item_inbox[self.inbox_index], "item"))
if self.inbox_index else "None"))
# To properly display in-game text, it must be alphanumeric and uppercase.
# I also only allotted 32 bytes to each string in OpenGOAL, so we must truncate.
@staticmethod
def sanitize_game_text(text: str) -> str:
if text is None:
return "\"NONE\""
result = "".join(c for c in text if (c in {"-", " "} or c.isalnum()))
result = result[:32].upper()
return f"\"{result}\""
# OpenGOAL can handle both its own string datatype and C-like character pointers (charp).
# So for the game to constantly display this information in the HUD, we have to write it
# to a memory address as a char*.
def write_game_text(self):
logger.debug(f"Sending info to in-game display!")
self.send_form(f"(charp<-string (-> *ap-info-jak1* my-item-name) "
f"{self.sanitize_game_text(self.my_item_name)})",
print_ok=False)
self.send_form(f"(charp<-string (-> *ap-info-jak1* my-item-finder) "
f"{self.sanitize_game_text(self.my_item_finder)})",
print_ok=False)
self.send_form(f"(charp<-string (-> *ap-info-jak1* their-item-name) "
f"{self.sanitize_game_text(self.their_item_name)})",
print_ok=False)
self.send_form(f"(charp<-string (-> *ap-info-jak1* their-item-owner) "
f"{self.sanitize_game_text(self.their_item_owner)})",
print_ok=False)
def receive_item(self):
ap_id = getattr(self.item_inbox[self.inbox_index], "item")
@@ -308,12 +343,12 @@ class JakAndDaxterReplClient:
logger.error(f"Unable to reset deathlink flag!")
return ok
def setup_orbsanity(self, option: int, bundle: int) -> bool:
ok = self.send_form(f"(ap-setup-orbs! (the uint {option}) (the uint {bundle}))")
def subtract_traded_orbs(self, orb_count: int) -> bool:
ok = self.send_form(f"(-! (-> *game-info* money) (the float {orb_count}))")
if ok:
logger.debug(f"Set up orbsanity: Option {option}, Bundle {bundle}!")
logger.debug(f"Subtracting {orb_count} traded orbs!")
else:
logger.error(f"Unable to set up orbsanity: Option {option}, Bundle {bundle}!")
logger.error(f"Unable to subtract {orb_count} traded orbs!")
return ok
def reset_orbsanity(self) -> bool:
@@ -330,6 +365,24 @@ class JakAndDaxterReplClient:
logger.error(f"Unable to reset orb count for collected orbsanity bundle!")
return ok
def setup_options(self,
os_option: int, os_bundle: int,
fc_count: int, mp_count: int,
lt_count: int, goal_id: int) -> bool:
ok = 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 uint {goal_id}))")
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}, Completion GOAL {goal_id}... ")
if ok:
logger.debug(message + "Success!")
else:
logger.error(message + "Failed!")
return ok
def save_data(self):
with open("jakanddaxter_item_inbox.json", "w+") as f:
dump = {

View File

@@ -128,6 +128,30 @@ This will show you a list of all the moves in the game.
- Yellow items indicate you possess that move, but you are missing its prerequisites.
- Light blue items indicate you possess that move, as well as its prerequisites.
## What does Orbsanity do?
If you enable Orbsanity, Precursor Orbs will be turned into ordered lists of progressive checks. Every time you collect
a "bundle" of the correct number of orbs, you will trigger the next release in the list. Likewise, these bundles of orbs
will be added to the item pool to be randomized. There are several options to change the difficulty of this challenge.
- "Per Level" Orbsanity means the lists of orb checks are generated and populated for each level in the game.
- (Geyser Rock, Sandover Village, etc.)
- "Global" Orbsanity means there is only one list of checks for the entire game.
- It does not matter where you pick up the orbs, they all count toward the same list.
- The options with "Bundle Size" in the name indicate how many orbs are in a "bundle." This adds a number of Items
and Locations to the pool inversely proportional to the size of the bundle.
- 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.
### A WARNING ABOUT ORBSANITY OPTIONS
Unlike other settings, you CANNOT alter Orbsanity options after you generate a seed and start a game. **If you turn
Orbsanity OFF in the middle of an Orbsanity game, you will have NO way of completing the orb checks.** This may cause
you to miss important progression items and prevent you (and others) from completing the run.
When you connect your text client to the Archipelago Server, the server will tell the game what settings were chosen
for this seed, and the game will apply those settings automatically. You can verify (but DO NOT ALTER) these settings
by navigating to `Options`, then `Archipelago Options`.
## I got soft-locked and can't leave, how do I get out of here?
Open the game's menu, navigate to `Options`, then to `Archipelago Options`, then to `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

@@ -73,15 +73,17 @@ At this time, this method of setup works on Windows only, but Linux support is a
- You should see several messages appear after the compiler has run to 100% completion. If you see `The REPL is ready!` and `The Memory Reader is ready!` then that should indicate a successful startup.
- The game should then load in the title screen.
- You can *minimize* the 2 powershell windows, **BUT DO NOT CLOSE THEM.** They are 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.
- Start a new game in the title screen, and play through the cutscenes.
- Once you reach Geyser Rock, you can connect to the Archipelago server.
- Provide your slot/player name and hit Enter, and then start the game!
- Once you reach Geyser Rock, you can start the game!
- You can leave Geyser Rock immediately if you so choose - just step on the warp gate button.
***Returning / Async Game***
- One important note is to connect to the Archipelago server **AFTER** you load your save file. This is to allow AP to give you all the items you had previously.
- Otherwise, the same steps as New Game apply.
- 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.
- **THESE SETTINGS AFFECT LOADING AND SAVING OF SAVE FILES, SO IT IS IMPORTANT TO DO THIS FIRST.**
- Then, instead of choosing `New Game` in the title menu, choose `Load Game`, then choose the save file **CORRESPONDING TO YOUR CURRENT ARCHIPELAGO CONNECTION.**
## Troubleshooting
@@ -119,3 +121,4 @@ Input file iso_data/jak1/MUS/TWEAKVAL.MUS does not exist.
- The game needs to run in debug mode in order to allow the repl to connect to it. We hide the debug text on screen and play the game's introductory cutscenes properly.
- The powershell windows cannot be run as background processes due to how the repl works, so the best we can do is minimize them.
- The client is currently not very robust and doesn't handle failures gracefully. This may result in items not being delivered to the game, or location checks not being delivered to the server.
- Orbsanity checks may show up out of order in the text client.

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_fight, can_reach_orbs
@@ -155,7 +155,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_reach_orbs
from ..locs import CellLocations as Cells, ScoutLocations as Scouts
@@ -18,7 +18,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs
@@ -83,7 +83,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value
@@ -98,4 +98,4 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
multiworld.regions.append(orbs)
main_area.connect(orbs)
return [main_area]
return [main_area, temple_int_post_blue]

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_reach_orbs
from ..locs import ScoutLocations as Scouts
@@ -27,7 +27,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs
@@ -58,6 +58,8 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
final_boss = JakAndDaxterRegion("Final Boss", player, multiworld, level_name, 0)
final_door = JakAndDaxterRegion("Final Door", player, multiworld, level_name, 0)
# Jump Dive required for a lot of buttons, prepare yourself.
main_area.connect(robot_scaffolding, rule=lambda state:
state.has("Jump Dive", player)
@@ -105,6 +107,9 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
final_boss.connect(rotating_tower) # Take elevator back down.
# Final door. Need 100 power cells.
final_boss.connect(final_door, rule=lambda state: state.has("Power Cell", player, 100))
multiworld.regions.append(main_area)
multiworld.regions.append(robot_scaffolding)
multiworld.regions.append(jump_pad_room)
@@ -112,10 +117,11 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
multiworld.regions.append(bunny_room)
multiworld.regions.append(rotating_tower)
multiworld.regions.append(final_boss)
multiworld.regions.append(final_door)
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value
@@ -130,4 +136,4 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
multiworld.regions.append(orbs)
main_area.connect(orbs)
return [main_area, final_boss]
return [main_area, final_boss, final_door]

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_reach_orbs
from ..locs import CellLocations as Cells, ScoutLocations as Scouts
@@ -18,7 +18,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs
@@ -57,7 +57,12 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
capsule_room = JakAndDaxterRegion("Capsule Chamber", player, multiworld, level_name, 6)
# Use jump dive to activate button inside the capsule. Blue eco vent can ready the chamber and get the scout fly.
capsule_room.add_cell_locations([47], access_rule=lambda state: state.has("Jump Dive", player))
capsule_room.add_cell_locations([47], access_rule=lambda state:
state.has("Jump Dive", player)
and (state.has("Double Jump", player)
or state.has("Jump Kick", player)
or (state.has("Punch", player)
and state.has("Punch Uppercut", player))))
capsule_room.add_fly_locations([327729])
second_slide = JakAndDaxterRegion("Second Slide", player, multiworld, level_name, 31)
@@ -130,7 +135,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs
@@ -116,7 +116,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_reach_orbs
from ..locs import ScoutLocations as Scouts
@@ -34,7 +34,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_reach_orbs
from ..locs import CellLocations as Cells, ScoutLocations as Scouts
@@ -18,7 +18,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_trade, can_reach_orbs
@@ -63,7 +63,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_trade, can_reach_orbs
@@ -71,7 +71,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs
@@ -85,7 +85,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs
@@ -192,7 +192,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs
@@ -110,7 +110,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value

View File

@@ -1,7 +1,7 @@
from typing import List
from BaseClasses import CollectionState, MultiWorld
from .RegionBase import JakAndDaxterRegion
from .. import JakAndDaxterOptions
from .. import JakAndDaxterOptions, EnableOrbsanity
from ..Rules import can_free_scout_flies, can_trade, can_reach_orbs
from ..locs import CellLocations as Cells, ScoutLocations as Scouts
@@ -38,7 +38,7 @@ def build_regions(level_name: str, multiworld: MultiWorld, options: JakAndDaxter
# If Per-Level Orbsanity is enabled, build the special Orbsanity Region. This is a virtual region always
# accessible to Main Area. The Locations within are automatically checked when you collect enough orbs.
if options.enable_orbsanity.value == 1:
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
orbs = JakAndDaxterRegion("Orbsanity", player, multiworld, level_name)
bundle_size = options.level_orbsanity_bundle_size.value