Files
dockipelago/worlds/banjo_tooie/Hints.py
Jonathan Tinney 7971961166
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

501 lines
22 KiB
Python

import re
from typing import List, Set, Union, TYPE_CHECKING
from dataclasses import dataclass
from BaseClasses import ItemClassification, Location, LocationProgressType, CollectionState
from .Options import HintClarity, AddSignpostHintsToArchipelagoHints
from .Items import moves_table, bk_moves_table, progressive_ability_table
from .Locations import (MumboTokenBoss_table, MumboTokenGames_table, MumboTokenJinjo_table, all_location_table,
WorldUnlocks_table)
from .Names import locationName
if TYPE_CHECKING:
from . import BanjoTooieWorld
TOTAL_HINTS = 61
@dataclass
class HintData:
text: str # The displayed text in the game.
location_id: Union[int, None] = None
location_player_id: Union[int, None] = None
should_add_hint: bool = False
class Hint:
submitted_cryptic_hinted_locations: Set[Location] = set()
# For each locations tested, lists who CANNOT beat their seed without collecting the location.
item_requirement_cache: dict[Location, List[int]] = {}
final_state: CollectionState | None = None
def __init__(self, world: "BanjoTooieWorld", location: Location):
self.world = world
self.location = location
@staticmethod
def submit_hinted_locations(hinted_locations: Set[Location]):
Hint.submitted_cryptic_hinted_locations.update(hinted_locations)
@staticmethod
def compute_item_requirement_cache(world: "BanjoTooieWorld") -> None:
# Since this function is complicated, and I think that some people would want this, let me explain it in plain English.
# Core concept: calling CollectionState.sweep_for_advancements by passing locations that haven't been checked by the state in the
# locations_checked parameter allows you to compute everything that is reachable without collecting those locations, effectively excluding them from the computation.
# First, we apply the status of not required to any location that has an item that's not progression.
# Then, while there's still locations that haven't been computed:
# Update a base state to the point where it reaches everything except everything that is locked behind the locations that haven't computed.
# Get the remaining locations that are currently reachable by the base state.
# Nothing reachable? The remaining locations are marked as not required, since they're unreachable locations.
# Split the reachable locations in batches of BATCH_SIZE locations. (Value gotten experimentally with seeds of 64 Tooies with max options, about 69k total locations.)
# For each batch:
# Create a copy of base state, and advance it, excluding only the locations from the current batch.
# For each location in current batch:
# Create a test state for that one location.
# Advance the test state, excluding only that one location.
# Note down every world id whose completion is locked by that location.
# Nobody's goal is locked by that location? The other hinted locations that are locked by that location are also not required.
# Update base state, allowing it to collect the locations in the batch.
# At the very end, we have the base state has done sweep_for_advancements with no excluded locations. We save that state, so that we can determine
# which of the hinted locations are not reachable, so that we can say "lost" in the cryptic hint.
BATCH_SIZE = 25
# In case of when everybody with cryptic hints has signpost_hints = 0
if not Hint.submitted_cryptic_hinted_locations:
return
# Already computed by a previous world.
if Hint.item_requirement_cache:
return
for location in {loc for loc in Hint.submitted_cryptic_hinted_locations if not loc.advancement}:
Hint.item_requirement_cache[location] = []
worthwhile_locations = {loc for loc in Hint.submitted_cryptic_hinted_locations if loc.advancement and loc not in Hint.item_requirement_cache}
base_state = CollectionState(world.multiworld)
while worthwhile_locations:
base_state.sweep_for_advancements(checked_locations=base_state.locations_checked | worthwhile_locations)
current_sphere_locations = {loc for loc in worthwhile_locations if base_state.can_reach(loc)}
if not current_sphere_locations:
# Remaining locations are unreachable, so not required.
for location in worthwhile_locations:
Hint.item_requirement_cache[location] = []
break
for location in current_sphere_locations:
worthwhile_locations.remove(location)
while current_sphere_locations:
# Doing things in batches saves a lot of time, since some locations can slow things down significantly.
location_batch = [current_sphere_locations.pop() for _ in range(min(len(current_sphere_locations), BATCH_SIZE))]
batch_state = base_state.copy()
batch_state.sweep_for_advancements(checked_locations=base_state.locations_checked | set(location_batch))
while location_batch:
# Useful print to see the progress!
# print(len(worthwhile_locations), len(current_sphere_locations), len(location_batch))
loc = world.random.choice(location_batch)
location_batch.remove(loc)
test_state = batch_state.copy()
test_state.sweep_for_advancements(checked_locations=test_state.locations_checked | {loc})
Hint.item_requirement_cache[loc] = [player for player in world.multiworld.player_ids if not world.multiworld.has_beaten_game(test_state, player)]
# If collecting one location is not required, then everything that remains unreachable without collecting that location is also not required.
if not Hint.item_requirement_cache[loc]:
for unreachable in {location for location in worthwhile_locations if not test_state.can_reach(location)}:
worthwhile_locations.remove(unreachable)
Hint.item_requirement_cache[unreachable] = []
# We finished a batch, so we update the base state by collecting the batch so that we no longer have compute what gets unlocked by anything in that batch.
base_state.sweep_for_advancements(checked_locations=base_state.locations_checked | worthwhile_locations | current_sphere_locations)
# Save final state for lost hints
Hint.final_state = base_state
@staticmethod
def is_last_cryptic_hint_world(world: "BanjoTooieWorld"):
tooie_worlds: List[BanjoTooieWorld] = [
tooie_world
for tooie_world in world.multiworld.worlds.values()
if tooie_world.game == world.game
]
cryptic_hint_worlds = [
tooie_world
for tooie_world in tooie_worlds
if tooie_world.options.hint_clarity.value == HintClarity.option_cryptic
]
return cryptic_hint_worlds[-1] == world
# TODO: have some fun with Grunty's rhymes here
@property
def hint_data(self) -> HintData:
if self.world.options.hint_clarity.value == HintClarity.option_clear:
text = self.__clear_hint_text
else:
text = self.__cryptic_hint_text
return HintData(text, self.location.address, self.location.player, self.__should_add_hint)
@property
def one_of_a_kind(self) -> bool:
if not self.location.item.advancement:
return False
count = 0
for item in self.world.multiworld.itempool:
if item.player == self.location.player and item.name == self.location.item.name:
count += 1
if count > 1:
return False
return count == 1
def __format_accessibility(self) -> str:
return "" if Hint.final_state.can_reach(self.location) else "lost "
def __format_location(self, capitalize: bool) -> str:
if self.location.player == self.world.player:
return f"{'Your' if capitalize else 'your'} {self.location.name}"
return f"{Hint.__player_id_to_name(self.world, self.location.player)}'s {Hint.__sanitize_text(self.location.name)}"
def __format_item(self, capitalize: bool) -> str:
if self.location.item.player == self.world.player:
return f"{'Your' if capitalize else 'your'} {Hint.__sanitize_text(self.location.item.name)}"
return f"{Hint.__player_id_to_name(self.world, self.location.item.player)}'s {Hint.__sanitize_text(self.location.item.name)}"
@property
def __clear_hint_text(self) -> str:
return f"{self.__format_location(capitalize=True)} has {self.__format_item(capitalize=False)}."
@property
def __cryptic_hint_text(self) -> str:
formatted_location = self.__format_location(capitalize=True)
formatted_accessibility = self.__format_accessibility()
if self.location.item.advancement:
if self.world.player in Hint.item_requirement_cache[self.location]:
return f"{formatted_location} is on the Wahay of the Duo."
if Hint.item_requirement_cache[self.location]:
return f"{formatted_location} is on the Wahay of the Archipelago."
if self.one_of_a_kind:
return f"{formatted_location} has a {formatted_accessibility}legendary one-of-a-kind item."
if self.location.item.classification == ItemClassification.progression:
return f"{formatted_location} has a {formatted_accessibility}wonderful item."
if ItemClassification.skip_balancing in self.location.item.classification \
or ItemClassification.deprioritized in self.location.item.classification:
return f"{formatted_location} has a {formatted_accessibility}great item."
if self.location.item.classification == ItemClassification.useful:
return f"{formatted_location} has a {formatted_accessibility}good item."
if self.location.item.classification == ItemClassification.filler:
return f"{formatted_location} has a {formatted_accessibility}useless item."
if self.location.item.classification == ItemClassification.trap:
return f"{formatted_location} has a {formatted_accessibility}bad item."
# Not sure what actually fits in the remaining multi-flag classifications
return f"{formatted_location} has a weiiiiiird item."
@property
def __should_add_hint(self) -> bool:
hint_clarity = self.world.options.hint_clarity
if hint_clarity == HintClarity.option_cryptic:
return False
ap_hinting = self.world.options.add_signpost_hints_to_ap
if ap_hinting == AddSignpostHintsToArchipelagoHints.option_always:
return True
if ap_hinting == AddSignpostHintsToArchipelagoHints.option_progression:
return self.location.item.advancement
# option_never
return False
@staticmethod
def __player_id_to_name(world: "BanjoTooieWorld", player: int) -> str:
return Hint.__sanitize_text(world.multiworld.player_name[player])
@staticmethod
def __sanitize_text(text: str) -> str:
N = 18
text = text.replace('_', ' ')
words = text.split()
modified_words = []
for word in words:
if len(word) > N:
# Try to split by PascalCase (uppercase letters within the word)
split_by_pascal = re.sub(r'([a-z])([A-Z])', r'\1 \2', word)
if word != split_by_pascal:
modified_words.append(split_by_pascal)
else:
forced_split = ' '.join(word[i:i+N] for i in range(0, len(word), N))
modified_words.append(forced_split)
else:
modified_words.append(word)
return ' '.join(modified_words)
def generate_hint_data(world: "BanjoTooieWorld"):
if world.options.hint_clarity.value == HintClarity.option_cryptic:
Hint.compute_item_requirement_cache(world)
hint_data = [Hint(world, location).hint_data for location in world.hinted_locations]
generate_joke_hints(world, hint_data)
# Since these are static variables, we have to manually empty them.
# Using del messes up with tests, so we're simply deleting the reference.
if world.options.hint_clarity.value == HintClarity.option_cryptic:
if Hint.is_last_cryptic_hint_world(world):
Hint.item_requirement_cache = dict()
Hint.submitted_cryptic_hinted_locations = set()
Hint.final_state = None
world.random.shuffle(hint_data)
world.hints = dict(zip(get_signpost_location_ids(), hint_data))
def generate_joke_hints(world: "BanjoTooieWorld", hints: List[HintData]):
# Fills the rest of the signposts with jokes.
if len(hints) == TOTAL_HINTS:
return
generate_suggestion_hint(world, hints)
generate_forced_joke_hint(world, hints)
generate_generic_joke_hint(world, hints)
def generate_forced_joke_hint(world: "BanjoTooieWorld", hint_datas: List[HintData]):
if len(hint_datas) == TOTAL_HINTS:
return
hint_datas.append(HintData(f"Sorry {world.player_name}, but we are not adding that feature in this game."))
def generate_generic_joke_hint(world: "BanjoTooieWorld", hint_datas: List[HintData]):
selected_jokes = (world.random.choices([
"A hint is what you want, but instead here's a taunt.",
"This is an information signpost.",
"This joke hint features no newline.",
"Press \x86 to read this signpost.", # That's the B button
"Banjo-Kazooie: Grunty's Revenge is a collectathon that was released on the GBA.",
"Did you know that Banjo-Kazooie had 2 mobile games? Me neither.",
"After collecting all 9 black jinjos, enter their house for a happy sound.",
"Made you look!",
"Developer jjjj12212 was a good developer... until he got shot with an arrow in the knee.",
# The following are quotes from other video games (or something inspired from them).
"Thank you Banjo, but your hint is on another signpost!",
"It's dangerous to go alone, read this!",
"I like shorts! They're comfy and easy to wear!",
"Press F to pay respects.",
"Press \x86 to doubt.",
"The sign is a lie",
"When life gives you wood, don't make signs! Make life take the wood back! Get mad!",
], k=TOTAL_HINTS - len(hint_datas)))
for joke in selected_jokes:
hint_datas.append(HintData(joke))
def generate_suggestion_hint(world: "BanjoTooieWorld", hint_datas: List[HintData]):
non_tooie_player_names = [
world.player_name
for world in world.multiworld.worlds.values()
if world.game != "Banjo-Tooie"
]
if not non_tooie_player_names:
return
hint = "You should suggest {} to try the Banjo-Tooie Randomizer.".format(
world.random.choice(non_tooie_player_names)
)
hint_datas.append(HintData(hint))
SLOW_LOCATION_NAMES = [
locationName.JINJOGM1,
locationName.JINJOGI3,
locationName.JINJOGI5,
locationName.JIGGYMT1,
locationName.JIGGYMT5,
locationName.JIGGYGM5,
locationName.JIGGYMT3,
locationName.JIGGYWW1,
locationName.JIGGYWW2,
locationName.JIGGYWW3,
locationName.JIGGYWW4,
locationName.JIGGYWW5,
locationName.JIGGYWW7,
locationName.JIGGYWW8,
locationName.JIGGYJR3,
locationName.JIGGYJR4,
locationName.JIGGYJR9,
locationName.JIGGYTD3,
locationName.JIGGYTD5,
locationName.JIGGYTD6,
locationName.JIGGYTD7,
locationName.JIGGYGI1,
locationName.JIGGYGI2,
locationName.JIGGYGI3,
locationName.JIGGYGI4,
locationName.JIGGYGI6,
locationName.JIGGYGI9,
locationName.JIGGYHP1,
locationName.JIGGYHP3,
locationName.JIGGYHP5,
locationName.JIGGYHP6,
locationName.JIGGYHP7,
locationName.JIGGYHP8,
locationName.JIGGYHP9,
locationName.JIGGYHP10,
locationName.JIGGYCC2,
locationName.JIGGYCC3,
locationName.JIGGYCC4,
locationName.JIGGYCC7,
locationName.JIGGYIH5,
locationName.JIGGYIH6,
locationName.JIGGYIH7,
locationName.JIGGYIH8,
locationName.JIGGYIH9,
locationName.GLOWBOMEG,
locationName.HONEYCJR3,
locationName.CHEATOGM1,
locationName.CHEATOWW3,
locationName.CHEATOJR1,
locationName.CHEATOTL1,
locationName.CHEATOGI3,
locationName.CHEATOCC1,
locationName.CHEATOCC2,
locationName.CHEATOR3,
locationName.CHEATOR4,
locationName.CHEATOR5,
locationName.HONEYBR4,
locationName.HONEYBR5,
locationName.SCRUT,
locationName.SCRAT,
locationName.GROGGY,
locationName.GAMETTE,
locationName.BETETTE,
locationName.ALPHETTE,
locationName.SKIVF1,
locationName.SKIVF2,
locationName.NESTGM15,
locationName.NESTTL35,
locationName.NESTGI50,
locationName.NESTGI51,
locationName.NESTGI52,
locationName.NESTHP21,
locationName.NESTHP22,
locationName.SIGNCC2,
locationName.WARPCK2,
]
def compute_item_requirement(world: "BanjoTooieWorld"):
if not Hint.item_requirement_cache:
Hint.compute_item_requirement_cache(world)
def choose_hinted_locations(world: "BanjoTooieWorld"):
choose_move_locations(world)
choose_slow_locations(world)
choose_random_locations(world)
if world.options.hint_clarity == HintClarity.option_cryptic:
Hint.submit_hinted_locations(world.hinted_locations)
def choose_random_locations(world: "BanjoTooieWorld"):
if len(world.hinted_locations) >= world.options.signpost_hints:
return
remaining_locations = [location for location in world.get_locations() if location not in world.hinted_locations and should_consider_location(location)]
world.random.shuffle(remaining_locations)
while len(world.hinted_locations) < world.options.signpost_hints:
world.hinted_locations.add(remaining_locations.pop())
def get_slow_location_weight(location: Location) -> int:
if location.item.classification == ItemClassification.progression:
return 20
elif location.item.classification & ItemClassification.progression_skip_balancing\
== ItemClassification.progression_skip_balancing\
or location.item.classification & ItemClassification.progression_deprioritized\
== ItemClassification.progression_deprioritized:
return 5
elif location.item.classification == ItemClassification.useful:
return 3
return 1
def choose_slow_locations(world: "BanjoTooieWorld"):
if len(world.hinted_locations) >= world.options.signpost_hints:
return
slow_locations = [location for location in world.get_locations() if location.name in SLOW_LOCATION_NAMES]
if world.options.randomize_bt_moves.value:
sorted_silos = [k for k, v in sorted(world.jamjars_siloname_costs.items(), key=lambda item: item[1])]
for _ in range(1, 7):
slow_locations.append(get_location_by_name(world, sorted_silos.pop()))
unhinted_slow_locations = list(set([location for location in slow_locations if location not in world.hinted_locations]))
weights = [get_slow_location_weight(hint) for hint in unhinted_slow_locations]
locations_weights = list(zip(unhinted_slow_locations, weights))
locations_weights = sorted(locations_weights, key=lambda x: world.random.random() / x[1])
weighted_locations = list(map(lambda lw: lw[0], locations_weights))
world.hinted_locations.update(weighted_locations[:world.options.signpost_hints.value - len(world.hinted_locations)])
def choose_move_locations(world: "BanjoTooieWorld"):
all_moves_names = []
# We don't want BT moves to be hinted when they're in the vanilla location.
if world.options.randomize_bt_moves.value:
all_moves_names.extend(moves_table.keys())
all_moves_names.extend(bk_moves_table.keys())
all_moves_names.extend(progressive_ability_table.keys())
all_move_locations = [location for location in get_all_hintable_locations(world)
if location.item.name in all_moves_names and location.item.player == world.player]
world.random.shuffle(all_move_locations)
for location in all_move_locations:
if len(world.hinted_locations) < min(
world.options.signpost_move_hints.value,
world.options.signpost_hints.value
):
world.hinted_locations.add(location)
def get_location_by_name(world: "BanjoTooieWorld", name: str) -> Location | None:
potential_match = [location for location in get_player_hintable_locations(world) if location.name == name]
if potential_match:
return potential_match[0]
return None
def get_all_hintable_locations(world: "BanjoTooieWorld") -> List[Location]:
return [location for location in world.multiworld.get_locations() if should_consider_location(location)]
def get_player_hintable_locations(world: "BanjoTooieWorld") -> List[Location]:
return [location for location in world.get_locations() if should_consider_location(location)]
def should_consider_location(location: Location) -> bool:
if not location.item or not location.address:
return False
if location.progress_type == LocationProgressType.EXCLUDED:
return False
location_hint_blacklist = [
*WorldUnlocks_table.keys(),
*MumboTokenBoss_table.keys(),
*MumboTokenGames_table.keys(),
*MumboTokenJinjo_table.keys(),
]
return location.name not in location_hint_blacklist
def get_signpost_location_ids() -> List[int]:
return [location_data.btid for location_data in all_location_table.values() if location_data.group == "Signpost"]