Skip to content

Commit

Permalink
Merge pull request #6 from dewiniaid/hk-costsanity
Browse files Browse the repository at this point in the history
HK: Costsanity
  • Loading branch information
dewiniaid authored Jun 26, 2022
2 parents 843ea9e + 267cfd6 commit 571da83
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 21 deletions.
40 changes: 38 additions & 2 deletions worlds/hk/Options.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typing
from .ExtractedData import logic_options, starts, pool_options
from .Rules import cost_terms

from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, SpecialRange
from .Charms import vanilla_costs, names as charm_names
Expand Down Expand Up @@ -425,6 +426,39 @@ class StartingGeo(Range):
default = 0


class CostSanity(Toggle):
"""If enabled, most locations with costs (like stag stations) will have randomly determined costs.
These costs can be in Geo (except Grubfather, Seer and Eggshop), Grubs, Charms, Essence and/or Rancid Eggs
"""
display_name = "Cost Sanity"


class CostSanityHybridChance(Range):
"""The chance that a CostSanity cost will include two components instead of one, e.g. Grubs + Essence"""
range_end = 100
default = 10


cost_sanity_weights: typing.Dict[str, type(Option)] = {}
for term, cost in cost_terms.items():
option_name = f"CostSanity{cost.option}Weight"
extra_data = {
"__module__": __name__, "range_end": 1000,
"__doc__": (
"The likelihood of Costsanity choosing a {cost.option} cost."
" Chosen as a sum of all weights from other types."
),
"default": cost.weight
}
if cost == 'GEO':
extra_data["__doc__"] += " Geo costs will never be chosen for Grubfather, Seer, or Egg Shop."

option = type(option_name, (Range,), extra_data)
globals()[option.__name__] = option
cost_sanity_weights[option.__name__] = option


hollow_knight_options: typing.Dict[str, type(Option)] = {
**hollow_knight_randomize_options,
RandomizeElevatorPass.__name__: RandomizeElevatorPass,
Expand All @@ -443,7 +477,9 @@ class StartingGeo(Range):
SalubraShopSlots, SalubraCharmShopSlots,
LegEaterShopSlots, GrubfatherRewardSlots,
SeerRewardSlots, ExtraShopSlots,
SplitCrystalHeart, SplitMothwingCloak, SplitMantisClaw
SplitCrystalHeart, SplitMothwingCloak, SplitMantisClaw,
CostSanity, CostSanityHybridChance,
)
}
},
**cost_sanity_weights
}
12 changes: 7 additions & 5 deletions worlds/hk/Rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ class CostTerm(NamedTuple):
option: str
singular: str
plural: str
weight: int # CostSanity
sort: int


cost_terms = {x.term: x for x in (
CostTerm("RANCIDEGGS", "Egg", "Rancid Egg", "Rancid Eggs"),
CostTerm("GRUBS", "Grub", "Grub", "Grubs"),
CostTerm("ESSENCE", "Essence", "Essence", "Essence"),
CostTerm("CHARMS", "Charm", "Charm", "Charms"),
CostTerm("GEO", "Geo", "Geo", "Geo"),
CostTerm("RANCIDEGGS", "Egg", "Rancid Egg", "Rancid Eggs", 1, 3),
CostTerm("GRUBS", "Grub", "Grub", "Grubs", 1, 2),
CostTerm("ESSENCE", "Essence", "Essence", "Essence", 1, 4),
CostTerm("CHARMS", "Charm", "Charm", "Charms", 1, 1),
CostTerm("GEO", "Geo", "Geo", "Geo", 8, 9999),
)}


Expand Down
119 changes: 105 additions & 14 deletions worlds/hk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import logging
import typing
from copy import deepcopy
import itertools
import operator

logger = logging.getLogger("Hollow Knight")

Expand All @@ -16,7 +19,6 @@

from BaseClasses import Region, Entrance, Location, MultiWorld, Item, RegionType, LocationProgressType, Tutorial, ItemClassification
from ..AutoWorld import World, LogicMixin, WebWorld
from copy import deepcopy

path_of_pain_locations = {
"Soul_Totem-Path_of_Pain_Below_Thornskip",
Expand Down Expand Up @@ -323,6 +325,11 @@ def _add(item_name: str, location_name: str):

self.world.itempool += pool

self.apply_costsanity()

self.sort_shops_by_cost()

def sort_shops_by_cost(self):
for shop, locations in self.created_multi_locations.items():
randomized_locations = list(loc for loc in locations if not loc.vanilla)
prices = sorted(
Expand All @@ -332,6 +339,69 @@ def _add(item_name: str, location_name: str):
for loc, costs in zip(randomized_locations, prices):
loc.costs = costs

def apply_costsanity(self):
if not self.world.CostSanity[self.player].value:
return # noop

def _compute_weights(weights: dict, desc: str) -> typing.Dict[str, int]:
if all(x == 0 for x in weights.values()):
logger.warning(
f"All {desc} weights were zero for {self.world.player_name[self.player]}."
f" Setting them to one instead."
)
weights = {k: 1 for k in weights}

return {k: v for k, v in weights.items() if v}

random = self.world.random
hybrid_chance = getattr(self.world, f"CostSanityHybridChance")[self.player].value
weights = {
data.term: getattr(self.world, f"CostSanity{data.option}Weight")[self.player].value
for data in cost_terms.values()
}
weights_geoless = dict(weights)
del weights_geoless["GEO"]

weights = _compute_weights(weights, "CostSanity")
weights_geoless = _compute_weights(weights, "Geoless CostSanity")

if hybrid_chance > 0:
if len(weights) == 1:
logger.warning(
f"Only one cost type is available for CostSanity in {self.world.player_name[self.player]}'s world."
f" CostSanityHybridChance will not trigger."
)
if len(weights_geoless) == 1:
logger.warning(
f"Only one cost type is available for CostSanity in {self.world.player_name[self.player]}'s world."
f" CostSanityHybridChance will not trigger in geoless locations."
)

for region in self.world.get_regions(self.player):
for location in region.locations:
if not location.costs:
continue

if location.basename in {'Grubfather', 'Seer', 'Eggshop'}:
our_weights = dict(weights_geoless)
else:
our_weights = dict(weights)

rolls = 1
if random.randrange(100) < hybrid_chance:
rolls = 2

if rolls > len(our_weights):
terms = list(our_weights.keys()) # Can't randomly choose cost types, using all of them.
else:
terms = []
for _ in range(rolls):
term = random.choices(list(our_weights.keys()), list(our_weights.values()))[0]
del our_weights[term]
terms.append(term)

location.costs = {term: random.randint(*self.ranges[term]) for term in terms}
location.sort_costs()

def set_rules(self):
world = self.world
Expand Down Expand Up @@ -365,14 +435,15 @@ def fill_slot_data(self):
slot_data["seed"] = self.world.slot_seeds[self.player].randint(-2147483647, 2147483646)

# Backwards compatibility for shop cost data (HKAP < 0.1.0)
for shop, terms in shop_cost_types.items():
unit = cost_terms[next(iter(terms))].option
if unit == "Geo":
continue
slot_data[f"{unit}_costs"] = {
loc.name: next(iter(loc.costs.values()))
for loc in self.created_multi_locations[shop]
}
if not self.world.CostSanity[self.player]:
for shop, terms in shop_cost_types.items():
unit = cost_terms[next(iter(terms))].option
if unit == "Geo":
continue
slot_data[f"{unit}_costs"] = {
loc.name: next(iter(loc.costs.values()))
for loc in self.created_multi_locations[shop]
}

# HKAP 0.1.0 and later cost data.
location_costs = {}
Expand All @@ -392,6 +463,7 @@ def create_item(self, name: str) -> HKItem:

def create_location(self, name: str, vanilla=False) -> HKLocation:
costs = None
basename = name
if name in shop_cost_types:
costs = {
term: self.world.random.randint(*self.ranges[term])
Expand All @@ -407,7 +479,9 @@ def create_location(self, name: str, vanilla=False) -> HKLocation:
name = f"{name}_{i}"

region = self.world.get_region("Menu", self.player)
loc = HKLocation(self.player, name, self.location_name_to_id[name], region, costs=costs, vanilla=vanilla)
loc = HKLocation(self.player, name,
self.location_name_to_id[name], region, costs=costs, vanilla=vanilla,
basename=basename)

if multi is not None:
multi.append(loc)
Expand Down Expand Up @@ -462,14 +536,23 @@ def stage_write_spoiler(cls, world: MultiWorld, spoiler_handle):
spoiler_handle.write(f'\n{name}\n')
hk_world: HKWorld = world.worlds[player]

for shop_name, locations in hk_world.created_multi_locations.items():
for loc in locations:
if world.CostSanity[player].value:
for loc in sorted(
(
loc for loc in itertools.chain(*(region.locations for region in world.get_regions(player)))
if loc.costs
), key=operator.attrgetter('name')
):
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")
else:
for shop_name, locations in hk_world.created_multi_locations.items():
for loc in locations:
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")

def get_multi_location_name(self, base: str, i: typing.Optional[int]) -> str:
if i is None:
i = len(self.created_multi_locations[base]) + 1
assert i <= 16, "limited number of multi location IDs reserved."
assert 1 <= 16, "limited number of multi location IDs reserved."
return f"{base}_{i}"

def get_filler_item_name(self) -> str:
Expand Down Expand Up @@ -506,15 +589,23 @@ class HKLocation(Location):
costs: typing.Dict[str, int] = None
unit: typing.Optional[str] = None
vanilla = False
basename: str

def sort_costs(self):
if self.costs is None:
return
self.costs = {k: self.costs[k] for k in sorted(self.costs.keys(), key=lambda x: cost_terms[x].sort)}

def __init__(
self, player: int, name: str, code=None, parent=None,
costs: typing.Dict[str, int] = None, vanilla: bool = False
costs: typing.Dict[str, int] = None, vanilla: bool = False, basename: str = None
):
self.basename = basename or name
super(HKLocation, self).__init__(player, name, code if code else None, parent)
self.vanilla = vanilla
if costs:
self.costs = dict(costs)
self.sort_costs()

def cost_text(self, separator=" and "):
if self.costs is None:
Expand Down

0 comments on commit 571da83

Please sign in to comment.