Item Classifications (and REPL fixes) (#49)

* Changes to item classifications

* Bugfixes to power cell thresholds.

* Fix bugs in item_type_helper.

* Refactor 100 cell door to pass unit tests.

* Quick fix to ReplClient.

* Not so quick fix to ReplClient.

* Display friendly limits in options tooltips.
This commit is contained in:
massimilianodelliubaldini
2024-09-12 09:29:59 -04:00
committed by GitHub
parent 8922b1a5c9
commit d15de80d57
5 changed files with 144 additions and 66 deletions

View File

@@ -160,10 +160,11 @@ class JakAndDaxterContext(CommonContext):
async def json_to_game_text(self, args: dict):
if "type" in args and args["type"] in {"ItemSend"}:
my_item_name = Optional[str]
my_item_finder = Optional[str]
their_item_name = Optional[str]
their_item_owner = Optional[str]
my_item_name: Optional[str] = None
my_item_finder: Optional[str] = None
their_item_name: Optional[str] = None
their_item_owner: Optional[str] = None
item = args["item"]
recipient = args["receiving"]

View File

@@ -29,7 +29,10 @@ class EnableOrbsanity(Choice):
class GlobalOrbsanityBundleSize(Choice):
"""Set the orb bundle size for Global Orbsanity. This only applies if "Enable Orbsanity" is set to "Global."
There are 2000 orbs in the game, so your bundle size must be a factor of 2000."""
There are 2000 orbs in the game, so your bundle size must be a factor of 2000.
Multiplayer Minimum: 10
Multiplayer Maximum: 200"""
display_name = "Global Orbsanity Bundle Size"
option_1_orb = 1
option_2_orbs = 2
@@ -58,7 +61,9 @@ class GlobalOrbsanityBundleSize(Choice):
class PerLevelOrbsanityBundleSize(Choice):
"""Set the orb bundle size for Per Level Orbsanity. This only applies if "Enable Orbsanity" is set to "Per Level."
There are 50, 150, or 200 orbs per level, so your bundle size must be a factor of 50."""
There are 50, 150, or 200 orbs per level, so your bundle size must be a factor of 50.
Multiplayer Minimum: 10"""
display_name = "Per Level Orbsanity Bundle Size"
option_1_orb = 1
option_2_orbs = 2
@@ -71,7 +76,10 @@ class PerLevelOrbsanityBundleSize(Choice):
class FireCanyonCellCount(Range):
"""Set the number of power cells you need to cross Fire Canyon."""
"""Set the number of power cells you need to cross Fire Canyon.
Multiplayer Maximum: 30
Singleplayer Maximum: 34"""
display_name = "Fire Canyon Cell Count"
range_start = 0
range_end = 100
@@ -81,7 +89,10 @@ class FireCanyonCellCount(Range):
class MountainPassCellCount(Range):
"""Set the number of power cells you need to reach Klaww and cross Mountain Pass."""
"""Set the number of power cells you need to reach Klaww and cross Mountain Pass.
Multiplayer Maximum: 60
Singleplayer Maximum: 63"""
display_name = "Mountain Pass Cell Count"
range_start = 0
range_end = 100
@@ -91,7 +102,10 @@ class MountainPassCellCount(Range):
class LavaTubeCellCount(Range):
"""Set the number of power cells you need to cross Lava Tube."""
"""Set the number of power cells you need to cross Lava Tube.
Multiplayer Maximum: 90
Singleplayer Maximum: 99"""
display_name = "Lava Tube Cell Count"
range_start = 0
range_end = 100
@@ -100,12 +114,14 @@ class LavaTubeCellCount(Range):
default = 72
# 222 is the maximum because there are 9 citizen trades and 2000 orbs to trade (2000/9 = 222).
# 222 is the absolute maximum because there are 9 citizen trades and 2000 orbs to trade (2000/9 = 222).
class CitizenOrbTradeAmount(Range):
"""Set the number of orbs you need to trade to ordinary citizens for a power cell (Mayor, Uncle, etc.).
Along with Oracle Orb Trade Amount, this setting cannot exceed the total number of orbs in the game (2000).
The equation to determine the total number of trade orbs is (9 * Citizen Trades) + (6 * Oracle Trades)."""
The equation to determine the total number of trade orbs is (9 * Citizen Trades) + (6 * Oracle Trades).
Multiplayer Maximum: 120"""
display_name = "Citizen Orb Trade Amount"
range_start = 0
range_end = 222
@@ -113,12 +129,14 @@ class CitizenOrbTradeAmount(Range):
default = 90
# 333 is the maximum because there are 6 oracle trades and 2000 orbs to trade (2000/6 = 333).
# 333 is the absolute maximum because there are 6 oracle trades and 2000 orbs to trade (2000/6 = 333).
class OracleOrbTradeAmount(Range):
"""Set the number of orbs you need to trade to the Oracles for a power cell.
Along with Citizen Orb Trade Amount, this setting cannot exceed the total number of orbs in the game (2000).
The equation to determine the total number of trade orbs is (9 * Citizen Trades) + (6 * Oracle Trades)."""
The equation to determine the total number of trade orbs is (9 * Citizen Trades) + (6 * Oracle Trades).
Multiplayer Maximum: 150"""
display_name = "Oracle Orb Trade Amount"
range_start = 0
range_end = 333

View File

@@ -1,4 +1,4 @@
from typing import Dict, Any, ClassVar, Tuple, Callable, Optional, Union
from typing import Dict, Any, ClassVar, Tuple, Callable, Optional, Union, List
import Utils
import settings
@@ -9,7 +9,7 @@ from BaseClasses import (Item,
Tutorial,
CollectionState)
from .GameID import jak1_id, jak1_name, jak1_max
from .JakAndDaxterOptions import JakAndDaxterOptions, EnableOrbsanity
from .JakAndDaxterOptions import JakAndDaxterOptions, EnableOrbsanity, CompletionCondition
from .Locations import (JakAndDaxterLocation,
location_table,
cell_location_table,
@@ -125,6 +125,7 @@ class JakAndDaxterWorld(World):
orb_bundle_item_name: str = ""
orb_bundle_size: int = 0
total_trade_orbs: int = 0
power_cell_thresholds: List[int] = []
# Handles various options validation, rules enforcement, and caching of important information.
def generate_early(self) -> None:
@@ -152,6 +153,16 @@ class JakAndDaxterWorld(World):
elif self.options.enable_orbsanity == EnableOrbsanity.option_global:
self.orb_bundle_size = self.options.global_orbsanity_bundle_size.value
self.orb_bundle_item_name = orb_item_table[self.orb_bundle_size]
else:
self.orb_bundle_size = 0
self.orb_bundle_item_name = ""
# Cache the power cell threshold values for quicker reference.
self.power_cell_thresholds = []
self.power_cell_thresholds.append(self.options.fire_canyon_cell_count.value)
self.power_cell_thresholds.append(self.options.mountain_pass_cell_count.value)
self.power_cell_thresholds.append(self.options.lava_tube_cell_count.value)
self.power_cell_thresholds.append(100) # The 100 Power Cell Door.
# Options drive which trade rules to use, so they need to be setup before we create_regions.
from .Rules import set_orb_trade_rule
@@ -165,47 +176,68 @@ class JakAndDaxterWorld(World):
# from Utils import visualize_regions
# visualize_regions(self.multiworld.get_region("Menu", self.player), "jakanddaxter.puml")
# Helper function to reuse some nasty if/else trees.
def item_type_helper(self, item) -> Tuple[int, ItemClass]:
# Make 101 Power Cells.
# 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]]:
counts_and_classes: List[Tuple[int, ItemClass]] = []
# Make 101 Power Cells. Not all of them will be Progression, some will be Filler. 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 - Enormous refactor.
if item in range(jak1_id, jak1_id + Scouts.fly_offset):
classification = ItemClass.progression_skip_balancing
count = 101
# 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 == 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))
# Make 7 Scout Flies per level.
elif item in range(jak1_id + Scouts.fly_offset, jak1_id + Specials.special_offset):
classification = ItemClass.progression_skip_balancing
count = 7
counts_and_classes.append((7, ItemClass.progression_skip_balancing))
# Make only 1 of each Special Item.
elif item in range(jak1_id + Specials.special_offset, jak1_id + Caches.orb_cache_offset):
classification = ItemClass.progression | ItemClass.useful
count = 1
counts_and_classes.append((1, ItemClass.progression | ItemClass.useful))
# Make only 1 of each Move Item.
elif item in range(jak1_id + Caches.orb_cache_offset, jak1_id + Orbs.orb_offset):
classification = ItemClass.progression | ItemClass.useful
count = 1
counts_and_classes.append((1, ItemClass.progression | ItemClass.useful))
# Make N Precursor Orb bundles, where N is 2000 / bundle size.
# 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):
if self.total_trade_orbs == 0:
classification = ItemClass.filler # If you don't need orbs to do trades, they are useless.
else:
classification = ItemClass.progression_skip_balancing
count = 2000 // self.orb_bundle_size if self.orb_bundle_size > 0 else 0 # Don't divide by zero!
# Under normal circumstances, we will create 0 filler items.
# We will manually create filler items as needed.
# Don't divide by zero!
if self.orb_bundle_size > 0:
item_count = 2000 // self.orb_bundle_size
prog_count = -(-self.total_trade_orbs // self.orb_bundle_size) # Lazy ceil using integer division.
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.
elif item == jak1_max:
classification = ItemClass.filler
count = 0
counts_and_classes.append((0, ItemClass.filler))
# If we try to make items with ID's higher than we've defined, something has gone wrong.
else:
raise KeyError(f"Tried to fill item pool with unknown ID {item}.")
return count, classification
return counts_and_classes
def create_items(self) -> None:
for item_name in self.item_name_to_id:
@@ -227,14 +259,15 @@ class JakAndDaxterWorld(World):
or item_name != self.orb_bundle_item_name)):
continue
# In every other scenario, do this.
count, classification = self.item_type_helper(item_id)
self.multiworld.itempool += [JakAndDaxterItem(item_name, classification, item_id, self.player)
for _ in range(count)]
# In every other scenario, do this. Not all items with the same name will have the same classification.
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)]
def create_item(self, name: str) -> Item:
item_id = self.item_name_to_id[name]
_, classification = self.item_type_helper(item_id)
_, classification = self.item_type_helper(item_id)[0] # Use first tuple (will likely be the most important).
return JakAndDaxterItem(name, classification, item_id, self.player)
def get_filler_item_name(self) -> str:
@@ -252,6 +285,17 @@ class JakAndDaxterWorld(World):
if item.name == self.orb_bundle_item_name:
state.prog_items[self.player]["Tradeable Orbs"] += self.orb_bundle_size # Give a bundle of Trade Orbs
# Scout Flies ALSO do not unlock anything that contains more Reachable Orbs, NOR do they give you more
# tradeable orbs. So let's just pass on them.
elif item.name in self.item_name_groups["Scout Flies"]:
pass
# Power Cells DO unlock new regions that contain more Reachable Orbs - the connector levels and new
# hub levels - BUT they only do that when you have a number of them equal to one of the threshold values.
elif (item.name == "Power Cell"
and state.count("Power Cell", self.player) not in self.power_cell_thresholds):
pass
# However, every other item that changes the CollectionState should set the cache to stale, because they
# likely made it possible to reach more orb locations (level unlocks, region unlocks, etc.).
else:
@@ -265,10 +309,22 @@ class JakAndDaxterWorld(World):
# Do the same thing we did in collect, except subtract trade orbs instead of add.
if item.name == self.orb_bundle_item_name:
state.prog_items[self.player]["Tradeable Orbs"] -= self.orb_bundle_size # Take a bundle of Trade Orbs
# Ditto Scout Flies.
elif item.name in self.item_name_groups["Scout Flies"]:
pass
# Ditto Power Cells, but check count + 1, because we potentially crossed the threshold in the opposite
# direction. E.g. we've removed the 20th power cell, our count is now 19, so we should stale the cache.
elif (item.name == "Power Cell"
and state.count("Power Cell", self.player) + 1 not in self.power_cell_thresholds):
pass
# Ditto everything else.
else:
state.prog_items[self.player]["Reachable Orbs Fresh"] = False
# TODO - 3.8 compatibility, remove this block when no longer required.
# TODO - Python 3.8 compatibility, remove this block when no longer required.
if state.prog_items[self.player]["Tradeable Orbs"] < 1:
del state.prog_items[self.player]["Tradeable Orbs"]
if state.prog_items[self.player]["Reachable Orbs"] < 1:

View File

@@ -210,33 +210,33 @@ class JakAndDaxterReplClient:
# 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}\""
# 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(self.JsonMessageData(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))
# 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*.
async def write_game_text(self, data: JsonMessageData):
logger.debug(f"Sending info to in-game display!")
await self.send_form(f"(begin "
f" (charp<-string (-> *ap-info-jak1* my-item-name) "
f" {self.sanitize_game_text(data.my_item_name)}) "
f" (charp<-string (-> *ap-info-jak1* my-item-finder) "
f" {self.sanitize_game_text(data.my_item_finder)}) "
f" (charp<-string (-> *ap-info-jak1* their-item-name) "
f" {self.sanitize_game_text(data.their_item_name)}) "
f" (charp<-string (-> *ap-info-jak1* their-item-owner) "
f" {self.sanitize_game_text(data.their_item_owner)}) "
f" (none))", print_ok=False)
body = ""
if data.my_item_name:
body += (f" (charp<-string (-> *ap-info-jak1* my-item-name)"
f" {self.sanitize_game_text(data.my_item_name)})")
if data.my_item_finder:
body += (f" (charp<-string (-> *ap-info-jak1* my-item-finder)"
f" {self.sanitize_game_text(data.my_item_finder)})")
if data.their_item_name:
body += (f" (charp<-string (-> *ap-info-jak1* their-item-name)"
f" {self.sanitize_game_text(data.their_item_name)})")
if data.their_item_owner:
body += (f" (charp<-string (-> *ap-info-jak1* their-item-owner)"
f" {self.sanitize_game_text(data.their_item_owner)})")
await self.send_form(f"(begin {body} (none))", print_ok=False)
async def receive_item(self):
ap_id = getattr(self.item_inbox[self.inbox_index], "item")

View File

@@ -2,7 +2,7 @@ from typing import List
from BaseClasses import CollectionState
from .RegionBase import JakAndDaxterRegion
from .. import EnableOrbsanity, JakAndDaxterWorld
from .. import EnableOrbsanity, JakAndDaxterWorld, CompletionCondition
from ..Rules import can_free_scout_flies, can_fight, can_reach_orbs_level
@@ -57,8 +57,6 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> List[JakAndDaxte
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) or state.has_all({"Roll", "Roll Jump"}, player))
@@ -101,9 +99,6 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> List[JakAndDaxte
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)
@@ -111,7 +106,6 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> List[JakAndDaxte
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.
@@ -127,4 +121,13 @@ def build_regions(level_name: str, world: JakAndDaxterWorld) -> List[JakAndDaxte
multiworld.regions.append(orbs)
main_area.connect(orbs)
return [main_area, final_boss, final_door]
# Final door. Need 100 power cells.
if options.jak_completion_condition == CompletionCondition.option_open_100_cell_door:
final_door = JakAndDaxterRegion("Final Door", player, multiworld, level_name, 0)
final_boss.connect(final_door, rule=lambda state: state.has("Power Cell", player, 100))
multiworld.regions.append(final_door)
return [main_area, final_boss, final_door]
else:
return [main_area, final_boss, None]