Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TUNIC: Grass Randomizer #3913

Open
wants to merge 41 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
9a6e17e
Fix certain items not being added to slot data
silent-destroyer Jul 8, 2024
436b3b3
Change where items get added to slot data
silent-destroyer Jul 8, 2024
10bdbb1
Add initial grass randomizer stuff
silent-destroyer Jul 14, 2024
4d00e92
Fix rules
silent-destroyer Jul 14, 2024
486e8a5
Update grass.py
silent-destroyer Jul 20, 2024
44ea69e
Remove wand and gun from logic
silent-destroyer Jul 20, 2024
82629d2
Merge remote-tracking branch 'upstream/main' into tunic/grass-rando
silent-destroyer Jul 23, 2024
651029f
Update __init__.py
silent-destroyer Jul 26, 2024
415cd48
Fix logic for two pieces of grass in atoll
silent-destroyer Jul 29, 2024
c4ec49d
Make early bushes only contain grass
silent-destroyer Sep 4, 2024
6fc52b4
Merge remote-tracking branch 'upstream/main' into tunic/grass-rando
silent-destroyer Sep 4, 2024
4991ca5
Backport changes to grass rando (#20)
ScipioWright Sep 8, 2024
f74d5bf
Update grass rando option descriptions
silent-destroyer Sep 8, 2024
dd31302
Ignore grass fill option for solo rando
silent-destroyer Sep 9, 2024
abb2ecc
Merge remote-tracking branch 'upstream/main' into tunic/grass-rando
silent-destroyer Sep 9, 2024
ac39fb1
Update er_rules.py
silent-destroyer Sep 9, 2024
d8e5a01
Fix pre fill issue
silent-destroyer Sep 9, 2024
05fb8fa
Remove duplicate option
silent-destroyer Sep 9, 2024
63d6639
Add excluded grass locations back
silent-destroyer Sep 9, 2024
0cfba94
Hide grass fill option from simple ui options page
silent-destroyer Sep 9, 2024
4a07006
Check for start with sword before setting grass rules
silent-destroyer Sep 9, 2024
031c1b0
Merge branch 'main' into tunic/grass-rando
silent-destroyer Sep 9, 2024
d19da8c
Update worlds/tunic/options.py
silent-destroyer Sep 10, 2024
5b062ba
Merge branch 'ArchipelagoMW:main' into tunic/grass-rando
silent-destroyer Oct 20, 2024
ed6fada
Exclude grass from get_filler_item_name
silent-destroyer Oct 20, 2024
b8c20a1
Merge remote-tracking branch 'upstream/main' into tunic/grass-rando
silent-destroyer Oct 23, 2024
f47d56d
Merge branch 'main' into tunic/grass-rando
silent-destroyer Nov 8, 2024
5b0faf7
Update worlds/tunic/__init__.py
silent-destroyer Nov 8, 2024
7bb3971
Merge branch 'main' into tunic/grass-rando
silent-destroyer Nov 27, 2024
3974c5d
Merge branch 'main' into tunic/grass-rando
silent-destroyer Dec 10, 2024
9c5ca73
Apply suggestions from code review
silent-destroyer Dec 10, 2024
129f588
change the rest of grass_fill to local_fill
silent-destroyer Dec 10, 2024
5aac5d5
Filter out grass from filler_items
silent-destroyer Dec 10, 2024
bdcaf4a
remove -> discard
silent-destroyer Dec 10, 2024
b5f05aa
Update worlds/tunic/__init__.py
silent-destroyer Dec 11, 2024
56be193
Merge remote-tracking branch 'upstream/main' into tunic/grass-rando
silent-destroyer Dec 15, 2024
717abeb
change has_stick to has_melee
silent-destroyer Dec 15, 2024
7239ea5
Update grass list with combat logic regions
silent-destroyer Dec 15, 2024
62b6e86
More fixes from combat logic merge
silent-destroyer Dec 15, 2024
d74eedc
Fix some dumb stuff (#21)
ScipioWright Dec 17, 2024
92d7e58
Reorganize pre fill for grass
silent-destroyer Dec 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 83 additions & 9 deletions worlds/tunic/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union, Set
from logging import warning
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState, LocationProgressType
from .items import (item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names,
combat_items)
from .locations import location_table, location_name_groups, location_name_to_id, hexagon_locations
from .locations import location_table, location_name_groups, standard_location_name_to_id, hexagon_locations, sphere_one
from .rules import set_location_rules, set_region_rules, randomize_ability_unlocks, gold_hexagon
from .er_rules import set_er_location_rules
from .regions import tunic_regions
from .er_scripts import create_er_regions
from .grass import grass_location_table, grass_location_name_to_id, grass_location_name_groups, excluded_grass_locations
from .er_data import portal_mapping, RegionInfo, tunic_er_regions
from .options import (TunicOptions, EntranceRando, tunic_option_groups, tunic_option_presets, TunicPlandoConnections,
LaurelsLocation, LogicRules, LaurelsZips, IceGrappling, LadderStorage)
from .combat_logic import area_data, CombatState
from worlds.AutoWorld import WebWorld, World
from Options import PlandoConnection
from Options import PlandoConnection, OptionError
from decimal import Decimal, ROUND_HALF_UP
from settings import Group, Bool

Expand All @@ -22,7 +23,11 @@ class TunicSettings(Group):
class DisableLocalSpoiler(Bool):
"""Disallows the TUNIC client from creating a local spoiler log."""

class LimitGrassRando(Bool):
"""Limits the impact of Grass Randomizer on the multiworld by disallowing local_fill percentages below 95."""

disable_local_spoiler: Union[DisableLocalSpoiler, bool] = False
limit_grass_rando: Union[LimitGrassRando, bool] = True


class TunicWeb(WebWorld):
Expand Down Expand Up @@ -73,18 +78,21 @@ class TunicWorld(World):
settings: ClassVar[TunicSettings]
item_name_groups = item_name_groups
location_name_groups = location_name_groups
location_name_groups.update(grass_location_name_groups)

item_name_to_id = item_name_to_id
location_name_to_id = location_name_to_id
location_name_to_id = standard_location_name_to_id.copy()
location_name_to_id.update(grass_location_name_to_id)

player_location_table: Dict[str, int]
ability_unlocks: Dict[str, int]
slot_data_items: List[TunicItem]
tunic_portal_pairs: Dict[str, str]
er_portal_hints: Dict[int, str]
seed_groups: Dict[str, SeedGroup] = {}
shop_num: int = 1 # need to make it so that you can walk out of shops, but also that they aren't all connected
er_regions: Dict[str, RegionInfo] # absolutely needed so outlet regions work

local_filler: List[TunicItem]
# so we only loop the multiworld locations once
# if these are locations instead of their info, it gives a memory leak error
item_link_locations: Dict[int, Dict[str, List[Tuple[int, str]]]] = {}
Expand Down Expand Up @@ -127,10 +135,20 @@ def generate_early(self) -> None:
self.options.hexagon_quest.value = passthrough["hexagon_quest"]
self.options.entrance_rando.value = passthrough["entrance_rando"]
self.options.shuffle_ladders.value = passthrough["shuffle_ladders"]
self.options.grass_randomizer.value = passthrough["grass_randomizer"]
self.options.fixed_shop.value = self.options.fixed_shop.option_false
self.options.laurels_location.value = self.options.laurels_location.option_anywhere
self.options.combat_logic.value = passthrough["combat_logic"]

self.player_location_table = standard_location_name_to_id.copy()

if self.options.grass_randomizer:
if self.settings.limit_grass_rando and self.options.local_fill < 95 and self.multiworld.players > 1:
raise OptionError(f"TUNIC: Player {self.player_name} has their Grass Fill option set too low. "
f"They must either bring it above 95% or the host needs to disable limit_grass_rando "
f"in their host.yaml settings")
self.player_location_table.update(grass_location_name_to_id)

silent-destroyer marked this conversation as resolved.
Show resolved Hide resolved
@classmethod
def stage_generate_early(cls, multiworld: MultiWorld) -> None:
tunic_worlds: Tuple[TunicWorld] = multiworld.get_game_worlds("TUNIC")
Expand Down Expand Up @@ -236,6 +254,14 @@ def create_items(self) -> None:
self.get_location("Secret Gathering Place - 10 Fairy Reward").place_locked_item(laurels)
items_to_create["Hero's Laurels"] = 0

if self.options.grass_randomizer:
items_to_create["Grass"] = len(grass_location_table)
tunic_items.append(self.create_item("Glass Cannon", ItemClassification.progression))
items_to_create["Glass Cannon"] = 0
for grass_location in excluded_grass_locations:
self.get_location(grass_location).place_locked_item(self.create_item("Grass"))
items_to_create["Grass"] -= len(excluded_grass_locations)

if self.options.keys_behind_bosses:
for rgb_hexagon, location in hexagon_locations.items():
hex_item = self.create_item(gold_hexagon if self.options.hexagon_quest else rgb_hexagon)
Expand Down Expand Up @@ -321,8 +347,53 @@ def remove_filler(amount: int) -> None:
if tunic_item.name in slot_data_item_names:
self.slot_data_items.append(tunic_item)

# pull out the filler so that we can place it manually during pre_fill
self.local_filler = []
if self.options.grass_randomizer and self.options.local_fill > 0 and self.multiworld.players > 1:
# skip items marked local or non-local, let fill deal with them in its own way
# discard grass from non_local if it's meant to be limited
silent-destroyer marked this conversation as resolved.
Show resolved Hide resolved
if self.settings.limit_grass_rando:
self.options.non_local_items.value.discard("Grass")
all_filler: List[TunicItem] = []
non_filler: List[TunicItem] = []
for tunic_item in tunic_items:
if (tunic_item.classification in [ItemClassification.filler, ItemClassification.trap]
and tunic_item.name not in self.options.local_items
and tunic_item.name not in self.options.non_local_items):
all_filler.append(tunic_item)
else:
non_filler.append(tunic_item)
amount_to_local_fill = int(self.options.local_fill.value * len(all_filler) / 100)
self.local_filler = all_filler[:amount_to_local_fill]
del all_filler[:amount_to_local_fill]
tunic_items = all_filler + non_filler

self.multiworld.itempool += tunic_items

@classmethod
def stage_pre_fill(cls, multiworld: MultiWorld) -> None:
tunic_grass_worlds: List[TunicWorld] = [world for world in multiworld.get_game_worlds("TUNIC")
if world.options.grass_randomizer]
tunic_players_with_grass: List[int] = [world.player for world in tunic_grass_worlds]
# we need to reserve a couple locations so that we don't fill up every sphere 1 location
reserved_locations: Set[str] = set(multiworld.random.sample(sphere_one, 2))
unfilled_locations = [loc for loc in multiworld.get_unfilled_locations_for_players(
silent-destroyer marked this conversation as resolved.
Show resolved Hide resolved
location_names=[], players=tunic_players_with_grass) if loc.progress_type != LocationProgressType.PRIORITY
and loc.name not in reserved_locations]
grass_filler_items: List[TunicItem] = []
for world in tunic_grass_worlds:
grass_filler_items.extend(world.local_filler)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could reorder this part and add an early return just so that you aren't calling get_unfilled_locations_for_players when it isn't needed.

Suggested change
unfilled_locations = [loc for loc in multiworld.get_unfilled_locations_for_players(
location_names=[], players=tunic_players_with_grass) if loc.progress_type != LocationProgressType.PRIORITY
and loc.name not in reserved_locations]
grass_filler_items: List[TunicItem] = []
for world in tunic_grass_worlds:
grass_filler_items.extend(world.local_filler)
grass_filler_items: List[TunicItem] = []
for world in tunic_grass_worlds:
grass_filler_items.extend(world.local_filler)
if not grass_filler_items:
return
unfilled_locations = [loc for loc in multiworld.get_unfilled_locations_for_players(
location_names=[], players=tunic_players_with_grass) if loc.progress_type != LocationProgressType.PRIORITY
and loc.name not in reserved_locations]

grass_filler_count = len(grass_filler_items)
# in case you plando or priority a bunch of locations
if len(unfilled_locations) < grass_filler_count:
raise Exception("Not enough locations for TUNIC grass randomizer players to place grass fill into TUNIC "
"locations. This is likely due to excessive priority locations or plando.")

locations_to_grass_fill = multiworld.random.sample(unfilled_locations, grass_filler_count)
for loc in locations_to_grass_fill:
multiworld.push_item(loc, grass_filler_items.pop(), collect=False)

def create_regions(self) -> None:
self.tunic_portal_pairs = {}
self.er_portal_hints = {}
Expand All @@ -337,7 +408,8 @@ def create_regions(self) -> None:
self.ability_unlocks["Pages 52-53 (Icebolt)"] = passthrough["Hexagon Quest Icebolt"]

# Ladders and Combat Logic uses ER rules with vanilla connections for easier maintenance
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
if (self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic
or self.options.grass_randomizer):
portal_pairs = create_er_regions(self)
if self.options.entrance_rando:
# these get interpreted by the game to tell it which entrances to connect
Expand All @@ -353,7 +425,7 @@ def create_regions(self) -> None:
region = self.get_region(region_name)
region.add_exits(exits)

for location_name, location_id in self.location_name_to_id.items():
for location_name, location_id in self.player_location_table.items():
region = self.get_region(location_table[location_name].region)
location = TunicLocation(self.player, location_name, location_id, region)
region.locations.append(location)
Expand All @@ -366,7 +438,8 @@ def create_regions(self) -> None:

def set_rules(self) -> None:
# same reason as in create_regions, could probably be put into create_regions
if self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic:
if (self.options.entrance_rando or self.options.shuffle_ladders or self.options.combat_logic
or self.options.grass_randomizer):
set_er_location_rules(self)
else:
set_region_rules(self)
Expand Down Expand Up @@ -454,6 +527,7 @@ def fill_slot_data(self) -> Dict[str, Any]:
"maskless": self.options.maskless.value,
"entrance_rando": int(bool(self.options.entrance_rando.value)),
"shuffle_ladders": self.options.shuffle_ladders.value,
"grass_randomizer": self.options.grass_randomizer.value,
"combat_logic": self.options.combat_logic.value,
"Hexagon Quest Prayer": self.ability_unlocks["Pages 24-25 (Prayer)"],
"Hexagon Quest Holy Cross": self.ability_unlocks["Pages 42-43 (Holy Cross)"],
Expand Down
14 changes: 10 additions & 4 deletions worlds/tunic/er_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,14 +627,16 @@ class DeadEnd(IntEnum):
"Beneath the Well Back": RegionInfo("Sewer"), # the back two portals, and all 4 upper chests
"West Garden before Terry": RegionInfo("Archipelagos Redux"), # the lower entry point, near hero grave
"West Garden after Terry": RegionInfo("Archipelagos Redux"), # after Terry, up until next chompignons
"West Garden West Combat": RegionInfo("Archipelagos Redux"), # for grass rando basically
"West Garden at Dagger House": RegionInfo("Archipelagos Redux"), # just outside magic dagger house
"West Garden South Checkpoint": RegionInfo("Archipelagos Redux"),
"West Garden South Checkpoint": RegionInfo("Archipelagos Redux"), # the checkpoint and the blue lines area
"Magic Dagger House": RegionInfo("archipelagos_house", dead_end=DeadEnd.all_cats),
"West Garden Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted, outlet_region="West Garden by Portal"),
"West Garden Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted,
outlet_region="West Garden by Portal"),
"West Garden by Portal": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Portal Item": RegionInfo("Archipelagos Redux", dead_end=DeadEnd.restricted),
"West Garden Laurels Exit Region": RegionInfo("Archipelagos Redux"),
"West Garden before Boss": RegionInfo("Archipelagos Redux"), # main west garden
"West Garden before Boss": RegionInfo("Archipelagos Redux"), # up the ladder before garden knight
"West Garden after Boss": RegionInfo("Archipelagos Redux"),
"West Garden Hero's Grave Region": RegionInfo("Archipelagos Redux", outlet_region="West Garden before Terry"),
"Ruined Atoll": RegionInfo("Atoll Redux"),
Expand Down Expand Up @@ -1152,8 +1154,10 @@ class DeadEnd(IntEnum):
"West Garden after Terry": {
"West Garden before Terry":
[],
"West Garden South Checkpoint":
"West Garden West Combat":
[],
"West Garden South Checkpoint":
[["Hyperdash"]],
"West Garden Laurels Exit Region":
[["LS1"]],
},
Expand All @@ -1163,6 +1167,8 @@ class DeadEnd(IntEnum):
"West Garden at Dagger House":
[],
"West Garden after Terry":
[["Hyperdash"]],
"West Garden West Combat":
[],
},
"West Garden before Boss": {
Expand Down
40 changes: 31 additions & 9 deletions worlds/tunic/er_rules.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from typing import Dict, FrozenSet, Tuple, TYPE_CHECKING
from worlds.generic.Rules import set_rule, add_rule, forbid_item
from BaseClasses import Region, CollectionState
from .options import IceGrappling, LadderStorage, CombatLogic
from .rules import (has_ability, has_sword, has_melee, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage,
laurels_zip, bomb_walls)
from .er_data import Portal, get_portal_outlet_region
from .ladder_storage_data import ow_ladder_groups, region_ladders, easy_ls, medium_ls, hard_ls
from .combat_logic import has_combat_reqs
from BaseClasses import Region, CollectionState
from .grass import set_grass_location_rules

if TYPE_CHECKING:
from . import TunicWorld
Expand Down Expand Up @@ -534,7 +535,6 @@ def get_paired_portal(portal_sd: str) -> Tuple[str, str]:
regions["Dark Tomb Upper"].connect(
connecting_region=regions["Dark Tomb Entry Point"])

# ice grapple through the wall, get the little secret sound to trigger
regions["Dark Tomb Upper"].connect(
connecting_region=regions["Dark Tomb Main"],
rule=lambda state: has_ladder("Ladder in Dark Tomb", state, world)
Expand All @@ -556,11 +556,24 @@ def get_paired_portal(portal_sd: str) -> Tuple[str, str]:
wg_after_to_before_terry = regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden before Terry"])

regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden South Checkpoint"])
wg_checkpoint_to_after_terry = regions["West Garden South Checkpoint"].connect(
wg_after_terry_to_west_combat = regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden West Combat"])
regions["West Garden West Combat"].connect(
connecting_region=regions["West Garden after Terry"])

wg_checkpoint_to_west_combat = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden West Combat"])
regions["West Garden West Combat"].connect(
connecting_region=regions["West Garden South Checkpoint"])

# if not laurels, it goes through the west combat region instead
regions["West Garden after Terry"].connect(
connecting_region=regions["West Garden South Checkpoint"],
rule=lambda state: state.has(laurels, player))
regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden after Terry"],
rule=lambda state: state.has(laurels, player))

wg_checkpoint_to_dagger = regions["West Garden South Checkpoint"].connect(
connecting_region=regions["West Garden at Dagger House"])
regions["West Garden at Dagger House"].connect(
Expand Down Expand Up @@ -1381,12 +1394,16 @@ def ls_connect(origin_name: str, portal_sdt: str) -> None:
set_rule(wg_after_to_before_terry,
lambda state: state.has_any({laurels, ice_dagger}, player)
or has_combat_reqs("West Garden", state, player))
# laurels through, probably to the checkpoint, or just fight
set_rule(wg_checkpoint_to_after_terry,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player))
set_rule(wg_checkpoint_to_before_boss,

set_rule(wg_after_terry_to_west_combat,
lambda state: has_combat_reqs("West Garden", state, player))
set_rule(wg_checkpoint_to_west_combat,
lambda state: has_combat_reqs("West Garden", state, player))

# maybe a little too generous? probably fine though
set_rule(wg_checkpoint_to_before_boss,
lambda state: state.has(laurels, player) or has_combat_reqs("West Garden", state, player))

add_rule(btv_front_to_main,
lambda state: has_combat_reqs("Beneath the Vault", state, player))
add_rule(btv_back_to_main,
Expand Down Expand Up @@ -1507,6 +1524,9 @@ def ls_connect(origin_name: str, portal_sdt: str) -> None:
def set_er_location_rules(world: "TunicWorld") -> None:
player = world.player

if world.options.grass_randomizer:
set_grass_location_rules(world)

forbid_item(world.get_location("Secret Gathering Place - 20 Fairy Reward"), fairies, player)

# Ability Shuffle Exclusive Rules
Expand Down Expand Up @@ -1831,6 +1851,8 @@ def combat_logic_to_loc(loc_name: str, combat_req_area: str, set_instead: bool =
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Faeries", "West Garden")
combat_logic_to_loc("West Garden - [Central Lowlands] Chest Beneath Save Point", "West Garden")
combat_logic_to_loc("West Garden - [West Highlands] Upper Left Walkway", "West Garden")
combat_logic_to_loc("West Garden - [Central Highlands] Holy Cross (Blue Lines)", "West Garden")
combat_logic_to_loc("West Garden - [Central Highlands] Behind Guard Captain", "West Garden")

# with combat logic on, I presume the player will want to be able to see to avoid the spiders
set_rule(world.get_location("Beneath the Fortress - Bridge"),
Expand Down
6 changes: 3 additions & 3 deletions worlds/tunic/er_scripts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Dict, List, Set, Tuple, TYPE_CHECKING
from BaseClasses import Region, ItemClassification, Item, Location
from .locations import location_table
from .locations import all_locations
from .er_data import Portal, portal_mapping, traversal_requirements, DeadEnd, RegionInfo
from .er_rules import set_er_region_rules
from Options import PlandoConnection
Expand Down Expand Up @@ -53,8 +53,8 @@ def create_er_regions(world: "TunicWorld") -> Dict[Portal, Portal]:

set_er_region_rules(world, regions, portal_pairs)

for location_name, location_id in world.location_name_to_id.items():
region = regions[location_table[location_name].er_region]
for location_name, location_id in world.player_location_table.items():
region = regions[all_locations[location_name].er_region]
location = TunicERLocation(world.player, location_name, location_id, region)
region.locations.append(location)

Expand Down
Loading
Loading