1- from typing import Dict , List , Any , Tuple , TypedDict , ClassVar , Union
1+ from typing import Dict , List , Any , Tuple , TypedDict , ClassVar , Union , Set
22from logging import warning
33from BaseClasses import Region , Location , Item , Tutorial , ItemClassification , MultiWorld , CollectionState
44from .items import (item_name_to_id , item_table , item_name_groups , fool_tiers , filler_items , slot_data_item_names ,
55 combat_items )
6- from .locations import location_table , location_name_groups , location_name_to_id , hexagon_locations
6+ from .locations import location_table , location_name_groups , standard_location_name_to_id , hexagon_locations , sphere_one
77from .rules import set_location_rules , set_region_rules , randomize_ability_unlocks , gold_hexagon
88from .er_rules import set_er_location_rules
99from .regions import tunic_regions
1010from .er_scripts import create_er_regions
11+ from .grass import grass_location_table , grass_location_name_to_id , grass_location_name_groups , excluded_grass_locations
1112from .er_data import portal_mapping , RegionInfo , tunic_er_regions
1213from .options import (TunicOptions , EntranceRando , tunic_option_groups , tunic_option_presets , TunicPlandoConnections ,
1314 LaurelsLocation , LogicRules , LaurelsZips , IceGrappling , LadderStorage )
1415from .combat_logic import area_data , CombatState
1516from worlds .AutoWorld import WebWorld , World
16- from Options import PlandoConnection
17+ from Options import PlandoConnection , OptionError
1718from decimal import Decimal , ROUND_HALF_UP
1819from settings import Group , Bool
1920
@@ -22,7 +23,11 @@ class TunicSettings(Group):
2223 class DisableLocalSpoiler (Bool ):
2324 """Disallows the TUNIC client from creating a local spoiler log."""
2425
26+ class LimitGrassRando (Bool ):
27+ """Limits the impact of Grass Randomizer on the multiworld by disallowing local_fill percentages below 95."""
28+
2529 disable_local_spoiler : Union [DisableLocalSpoiler , bool ] = False
30+ limit_grass_rando : Union [LimitGrassRando , bool ] = True
2631
2732
2833class TunicWeb (WebWorld ):
@@ -73,10 +78,13 @@ class TunicWorld(World):
7378 settings : ClassVar [TunicSettings ]
7479 item_name_groups = item_name_groups
7580 location_name_groups = location_name_groups
81+ location_name_groups .update (grass_location_name_groups )
7682
7783 item_name_to_id = item_name_to_id
78- location_name_to_id = location_name_to_id
84+ location_name_to_id = standard_location_name_to_id .copy ()
85+ location_name_to_id .update (grass_location_name_to_id )
7986
87+ player_location_table : Dict [str , int ]
8088 ability_unlocks : Dict [str , int ]
8189 slot_data_items : List [TunicItem ]
8290 tunic_portal_pairs : Dict [str , str ]
@@ -85,6 +93,11 @@ class TunicWorld(World):
8593 shop_num : int = 1 # need to make it so that you can walk out of shops, but also that they aren't all connected
8694 er_regions : Dict [str , RegionInfo ] # absolutely needed so outlet regions work
8795
96+ # for the local_fill option
97+ fill_items : List [TunicItem ]
98+ fill_locations : List [TunicLocation ]
99+ amount_to_local_fill : int
100+
88101 # so we only loop the multiworld locations once
89102 # if these are locations instead of their info, it gives a memory leak error
90103 item_link_locations : Dict [int , Dict [str , List [Tuple [int , str ]]]] = {}
@@ -132,6 +145,7 @@ def generate_early(self) -> None:
132145 self .options .hexagon_quest .value = self .passthrough ["hexagon_quest" ]
133146 self .options .entrance_rando .value = self .passthrough ["entrance_rando" ]
134147 self .options .shuffle_ladders .value = self .passthrough ["shuffle_ladders" ]
148+ self .options .grass_randomizer .value = self .passthrough .get ("grass_randomizer" , 0 )
135149 self .options .fixed_shop .value = self .options .fixed_shop .option_false
136150 self .options .laurels_location .value = self .options .laurels_location .option_anywhere
137151 self .options .combat_logic .value = self .passthrough ["combat_logic" ]
@@ -140,6 +154,22 @@ def generate_early(self) -> None:
140154 else :
141155 self .using_ut = False
142156
157+ self .player_location_table = standard_location_name_to_id .copy ()
158+
159+ if self .options .local_fill == - 1 :
160+ if self .options .grass_randomizer :
161+ self .options .local_fill .value = 95
162+ else :
163+ self .options .local_fill .value = 0
164+
165+ if self .options .grass_randomizer :
166+ if self .settings .limit_grass_rando and self .options .local_fill < 95 and self .multiworld .players > 1 :
167+ raise OptionError (f"TUNIC: Player { self .player_name } has their Local Fill option set too low. "
168+ f"They must either bring it above 95% or the host needs to disable limit_grass_rando "
169+ f"in their host.yaml settings" )
170+
171+ self .player_location_table .update (grass_location_name_to_id )
172+
143173 @classmethod
144174 def stage_generate_early (cls , multiworld : MultiWorld ) -> None :
145175 tunic_worlds : Tuple [TunicWorld ] = multiworld .get_game_worlds ("TUNIC" )
@@ -245,6 +275,14 @@ def create_items(self) -> None:
245275 self .get_location ("Secret Gathering Place - 10 Fairy Reward" ).place_locked_item (laurels )
246276 items_to_create ["Hero's Laurels" ] = 0
247277
278+ if self .options .grass_randomizer :
279+ items_to_create ["Grass" ] = len (grass_location_table )
280+ tunic_items .append (self .create_item ("Glass Cannon" , ItemClassification .progression ))
281+ items_to_create ["Glass Cannon" ] = 0
282+ for grass_location in excluded_grass_locations :
283+ self .get_location (grass_location ).place_locked_item (self .create_item ("Grass" ))
284+ items_to_create ["Grass" ] -= len (excluded_grass_locations )
285+
248286 if self .options .keys_behind_bosses :
249287 for rgb_hexagon , location in hexagon_locations .items ():
250288 hex_item = self .create_item (gold_hexagon if self .options .hexagon_quest else rgb_hexagon )
@@ -332,8 +370,73 @@ def remove_filler(amount: int) -> None:
332370 if tunic_item .name in slot_data_item_names :
333371 self .slot_data_items .append (tunic_item )
334372
373+ # pull out the filler so that we can place it manually during pre_fill
374+ self .fill_items = []
375+ if self .options .local_fill > 0 and self .multiworld .players > 1 :
376+ # skip items marked local or non-local, let fill deal with them in its own way
377+ # discard grass from non_local if it's meant to be limited
378+ if self .settings .limit_grass_rando :
379+ self .options .non_local_items .value .discard ("Grass" )
380+ all_filler : List [TunicItem ] = []
381+ non_filler : List [TunicItem ] = []
382+ for tunic_item in tunic_items :
383+ if (tunic_item .excludable
384+ and tunic_item .name not in self .options .local_items
385+ and tunic_item .name not in self .options .non_local_items ):
386+ all_filler .append (tunic_item )
387+ else :
388+ non_filler .append (tunic_item )
389+ self .amount_to_local_fill = int (self .options .local_fill .value * len (all_filler ) / 100 )
390+ self .fill_items += all_filler [:self .amount_to_local_fill ]
391+ del all_filler [:self .amount_to_local_fill ]
392+ tunic_items = all_filler + non_filler
393+
335394 self .multiworld .itempool += tunic_items
336395
396+ def pre_fill (self ) -> None :
397+ self .fill_locations = []
398+
399+ if self .options .local_fill > 0 and self .multiworld .players > 1 :
400+ # we need to reserve a couple locations so that we don't fill up every sphere 1 location
401+ reserved_locations : Set [str ] = set (self .random .sample (sphere_one , 2 ))
402+ viable_locations = [loc for loc in self .multiworld .get_unfilled_locations (self .player )
403+ if loc .name not in reserved_locations
404+ and loc .name not in self .options .priority_locations .value ]
405+
406+ if len (viable_locations ) < self .amount_to_local_fill :
407+ raise OptionError (f"TUNIC: Not enough locations for local_fill option for { self .player_name } . "
408+ f"This is likely due to excess plando or priority locations." )
409+
410+ self .fill_locations += viable_locations
411+
412+ @classmethod
413+ def stage_pre_fill (cls , multiworld : MultiWorld ) -> None :
414+ tunic_fill_worlds : List [TunicWorld ] = [world for world in multiworld .get_game_worlds ("TUNIC" )
415+ if world .options .local_fill .value > 0 ]
416+ if tunic_fill_worlds :
417+ grass_fill : List [TunicItem ] = []
418+ non_grass_fill : List [TunicItem ] = []
419+ grass_fill_locations : List [Location ] = []
420+ non_grass_fill_locations : List [Location ] = []
421+ for world in tunic_fill_worlds :
422+ if world .options .grass_randomizer :
423+ grass_fill .extend (world .fill_items )
424+ grass_fill_locations .extend (world .fill_locations )
425+ else :
426+ non_grass_fill .extend (world .fill_items )
427+ non_grass_fill_locations .extend (world .fill_locations )
428+
429+ multiworld .random .shuffle (grass_fill )
430+ multiworld .random .shuffle (non_grass_fill )
431+ multiworld .random .shuffle (grass_fill_locations )
432+ multiworld .random .shuffle (non_grass_fill_locations )
433+
434+ for filler_item in grass_fill :
435+ multiworld .push_item (grass_fill_locations .pop (), filler_item , collect = False )
436+
437+ for filler_item in non_grass_fill :
438+ multiworld .push_item (non_grass_fill_locations .pop (), filler_item , collect = False )
439+
337440 def create_regions (self ) -> None :
338441 self .tunic_portal_pairs = {}
339442 self .er_portal_hints = {}
@@ -346,7 +449,8 @@ def create_regions(self) -> None:
346449 self .ability_unlocks ["Pages 52-53 (Icebolt)" ] = self .passthrough ["Hexagon Quest Icebolt" ]
347450
348451 # Ladders and Combat Logic uses ER rules with vanilla connections for easier maintenance
349- if self .options .entrance_rando or self .options .shuffle_ladders or self .options .combat_logic :
452+ if (self .options .entrance_rando or self .options .shuffle_ladders or self .options .combat_logic
453+ or self .options .grass_randomizer ):
350454 portal_pairs = create_er_regions (self )
351455 if self .options .entrance_rando :
352456 # these get interpreted by the game to tell it which entrances to connect
@@ -362,7 +466,7 @@ def create_regions(self) -> None:
362466 region = self .get_region (region_name )
363467 region .add_exits (exits )
364468
365- for location_name , location_id in self .location_name_to_id .items ():
469+ for location_name , location_id in self .player_location_table .items ():
366470 region = self .get_region (location_table [location_name ].region )
367471 location = TunicLocation (self .player , location_name , location_id , region )
368472 region .locations .append (location )
@@ -375,7 +479,8 @@ def create_regions(self) -> None:
375479
376480 def set_rules (self ) -> None :
377481 # same reason as in create_regions, could probably be put into create_regions
378- if self .options .entrance_rando or self .options .shuffle_ladders or self .options .combat_logic :
482+ if (self .options .entrance_rando or self .options .shuffle_ladders or self .options .combat_logic
483+ or self .options .grass_randomizer ):
379484 set_er_location_rules (self )
380485 else :
381486 set_region_rules (self )
@@ -463,6 +568,7 @@ def fill_slot_data(self) -> Dict[str, Any]:
463568 "maskless" : self .options .maskless .value ,
464569 "entrance_rando" : int (bool (self .options .entrance_rando .value )),
465570 "shuffle_ladders" : self .options .shuffle_ladders .value ,
571+ "grass_randomizer" : self .options .grass_randomizer .value ,
466572 "combat_logic" : self .options .combat_logic .value ,
467573 "Hexagon Quest Prayer" : self .ability_unlocks ["Pages 24-25 (Prayer)" ],
468574 "Hexagon Quest Holy Cross" : self .ability_unlocks ["Pages 42-43 (Holy Cross)" ],
0 commit comments