forked from OoTRandomizer/OoT-Randomizer
-
Notifications
You must be signed in to change notification settings - Fork 21
/
World.py
1466 lines (1324 loc) · 90.1 KB
/
World.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
import copy
import json
import logging
import os
import random
from collections import OrderedDict, defaultdict
from collections.abc import Iterable, Iterator
from typing import Any, Optional
from Dungeon import Dungeon
from Entrance import Entrance
from Goals import Goal, GoalCategory
from HintList import get_required_hints, misc_item_hint_table, misc_location_hint_table
from Hints import HintArea, hint_dist_keys, hint_dist_files
from Item import Item, ItemFactory, ItemInfo, make_event_item
from ItemPool import reward_list
from Location import Location, LocationFactory
from LocationList import business_scrubs, location_groups, location_table
from OcarinaSongs import generate_song_list, Song
from Plandomizer import WorldDistribution, InvalidFileException
from Region import Region, TimeOfDay
from RuleParser import Rule_AST_Transformer
from Settings import Settings
from SettingsList import SettingInfos, get_settings_from_section
from Spoiler import Spoiler
from State import State
from Utils import data_path, read_logic_file
class World:
def __init__(self, world_id: int, settings: Settings, resolve_randomized_settings: bool = True) -> None:
self.id: int = world_id
self.dungeons: list[Dungeon] = []
self.regions: list[Region] = []
self.itempool: list[Item] = []
self._cached_locations: list[Location] = []
self._entrance_cache: dict[str, Entrance] = {}
self._region_cache: dict[str, Region] = {}
self._location_cache: dict[str, Location] = {}
self.shop_prices: dict[str, int] = {}
self.scrub_prices: dict[int, int] = {}
self.maximum_wallets: int = 0
self.hinted_dungeon_reward_locations: dict[str, Location] = {}
self.misc_hint_item_locations: dict[str, Location] = {}
self.misc_hint_location_items: dict[str, Item] = {}
self.triforce_count: int = 0
self.total_starting_triforce_count: int = 0
self.empty_areas: dict[HintArea, dict[str, Any]] = {}
self.barren_dungeon: int = 0
self.woth_dungeon: int = 0
self.randomized_list: list[str] = []
self.cached_bigocto_location: Optional[Location] = None
self.parser: Rule_AST_Transformer = Rule_AST_Transformer(self)
self.event_items: set[str] = set()
self.settings: Settings = settings
self.distribution: WorldDistribution = settings.distribution.world_dists[world_id]
# rename a few attributes...
self.keysanity: bool = settings.shuffle_smallkeys in ('keysanity', 'remove', 'any_dungeon', 'overworld', 'regional')
self.shuffle_silver_rupees = settings.shuffle_silver_rupees != 'vanilla'
self.check_beatable_only: bool = settings.reachable_locations != 'all'
self.shuffle_special_interior_entrances: bool = settings.shuffle_interior_entrances == 'all'
self.shuffle_interior_entrances: bool = settings.shuffle_interior_entrances in ('simple', 'all')
self.shuffle_special_dungeon_entrances: bool = settings.shuffle_dungeon_entrances == 'all'
self.shuffle_dungeon_entrances: bool = settings.shuffle_dungeon_entrances in ('simple', 'all')
self.entrance_shuffle: bool = bool(
self.shuffle_interior_entrances or settings.shuffle_grotto_entrances or self.shuffle_dungeon_entrances
or settings.shuffle_overworld_entrances or settings.shuffle_gerudo_valley_river_exit or settings.owl_drops or settings.warp_songs
or settings.spawn_positions or (settings.shuffle_bosses != 'off')
)
self.mixed_pools_bosses = False # this setting is still in active development at https://github.com/Roman971/OoT-Randomizer
self.ensure_tod_access: bool = bool(self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.spawn_positions)
self.disable_trade_revert: bool = self.shuffle_interior_entrances or settings.shuffle_overworld_entrances or settings.adult_trade_shuffle
self.skip_child_zelda: bool = 'Zeldas Letter' not in settings.shuffle_child_trade and \
'Zeldas Letter' in self.distribution.starting_items
self.selected_adult_trade_item: str = None
if not settings.adult_trade_shuffle and settings.adult_trade_start:
self.selected_adult_trade_item = random.choice(settings.adult_trade_start)
# Override the adult trade item used to control trade quest flags during patching if any are placed in plando.
# This has to run here because the rule parser caches world attributes and this attribute impacts logic for buying a blue potion from Granny's Potion shop.
adult_trade_matcher = self.distribution.pattern_matcher("#AdultTrade")
plando_adult_trade = list(filter(lambda location_record_pair: adult_trade_matcher(location_record_pair[1].item), self.distribution.pattern_dict_items(self.distribution.locations)))
if plando_adult_trade:
self.selected_adult_trade_item = plando_adult_trade[0][1].item # ugly but functional, see the loop in Plandomizer.WorldDistribution.fill for how this is indexed
self.adult_trade_starting_inventory: str = ''
if (settings.open_forest == 'closed'
and (self.shuffle_special_interior_entrances or settings.shuffle_hideout_entrances or settings.shuffle_overworld_entrances
or settings.warp_songs or settings.spawn_positions or settings.decouple_entrances or len(settings.mix_entrance_pools) > 1)):
self.settings.open_forest = 'closed_deku'
if settings.triforce_goal_per_world > settings.triforce_count_per_world:
raise ValueError("Triforces required cannot be more than the triforce count.")
self.triforce_goal: int = settings.triforce_goal_per_world * settings.world_count
if settings.triforce_hunt:
# Pin shuffle_ganon_bosskey to 'triforce' when triforce_hunt is enabled
# (specifically, for randomize_settings)
self.settings.shuffle_ganon_bosskey = 'triforce'
# trials that can be skipped will be decided later
self.skipped_trials: dict[str, bool] = {
'Spirit': False,
'Light': False,
'Fire': False,
'Shadow': False,
'Water': False,
'Forest': False,
}
# empty dungeons will be decided later
class EmptyDungeons(dict):
class EmptyDungeonInfo:
def __init__(self, boss_name: Optional[str]) -> None:
self.empty: bool = False
self.boss_name: Optional[str] = boss_name
self.hint_name: Optional[HintArea] = None
def __init__(self):
super().__init__()
self['Deku Tree'] = self.EmptyDungeonInfo('Queen Gohma')
self['Dodongos Cavern'] = self.EmptyDungeonInfo('King Dodongo')
self['Jabu Jabus Belly'] = self.EmptyDungeonInfo('Barinade')
self['Forest Temple'] = self.EmptyDungeonInfo('Phantom Ganon')
self['Fire Temple'] = self.EmptyDungeonInfo('Volvagia')
self['Water Temple'] = self.EmptyDungeonInfo('Morpha')
self['Spirit Temple'] = self.EmptyDungeonInfo('Twinrova')
self['Shadow Temple'] = self.EmptyDungeonInfo('Bongo Bongo')
for area in HintArea:
if area.is_dungeon and area.dungeon_name in self:
self[area.dungeon_name].hint_name = area
def __missing__(self, dungeon_name: str) -> EmptyDungeonInfo:
return self.EmptyDungeonInfo(None)
self.empty_dungeons: dict[str, EmptyDungeons.EmptyDungeonInfo] = EmptyDungeons()
# dungeon forms will be decided later
self.dungeon_mq: dict[str, bool] = {
'Deku Tree': False,
'Dodongos Cavern': False,
'Jabu Jabus Belly': False,
'Bottom of the Well': False,
'Ice Cavern': False,
'Gerudo Training Ground': False,
'Forest Temple': False,
'Fire Temple': False,
'Water Temple': False,
'Spirit Temple': False,
'Shadow Temple': False,
'Ganons Castle': False,
}
if resolve_randomized_settings:
self.resolve_random_settings()
self.song_notes: dict[str, Song] = generate_song_list(self,
frog=settings.ocarina_songs in ('frog', 'all'),
warp=settings.ocarina_songs in ('warp', 'all'),
)
if len(settings.hint_dist_user) == 0:
for d in hint_dist_files():
with open(d, 'r') as dist_file:
dist = json.load(dist_file)
if dist['name'] == self.settings.hint_dist:
self.hint_dist_user: dict[str, Any] = dist
else:
self.settings.hint_dist = 'custom'
self.hint_dist_user = self.settings.hint_dist_user
# Allow omitting hint types that shouldn't be included
for hint_type in hint_dist_keys:
if 'distribution' in self.hint_dist_user and hint_type not in self.hint_dist_user['distribution']:
self.hint_dist_user['distribution'][hint_type] = {"order": 0, "weight": 0.0, "fixed": 0, "copies": 0}
if 'use_default_goals' not in self.hint_dist_user:
self.hint_dist_user['use_default_goals'] = True
if 'upgrade_hints' not in self.hint_dist_user:
self.hint_dist_user['upgrade_hints'] = 'off'
if 'combine_trial_hints' not in self.hint_dist_user:
self.hint_dist_user['combine_trial_hints'] = False
# Validate hint distribution format
# Originally built when I was just adding the type distributions
# Location/Item Additions and Overrides are not validated
hint_dist_valid = False
if all(key in self.hint_dist_user['distribution'] for key in hint_dist_keys):
hint_dist_valid = True
sub_keys = {'order', 'weight', 'fixed', 'copies', 'remove_stones', 'priority_stones'}
for key in self.hint_dist_user['distribution']:
if not all(sub_key in sub_keys for sub_key in self.hint_dist_user['distribution'][key]):
hint_dist_valid = False
if not hint_dist_valid:
raise InvalidFileException("""Hint distributions require all hint types be present in the distro
(trial, always, dual_always, woth, barren, item, song, overworld, dungeon, entrance,
sometimes, dual, random, junk, named-item, goal). If a hint type should not be
shuffled, set its order to 0. Hint type format is \"type\": {
\"order\": 0, \"weight\": 0.0, \"fixed\": 0, \"copies\": 0,
\"remove_stones\": [], \"priority_stones\": [] }""")
self.added_hint_types: dict[str, list[str]] = {}
self.item_added_hint_types: dict[str, list[str]] = {}
self.hint_exclusions: set[str] = set()
if self.skip_child_zelda:
self.hint_exclusions.add('Song from Impa')
self.hint_type_overrides: dict[str, list[str]] = {}
self.item_hint_type_overrides: dict[str, list[str]] = {}
for dist in hint_dist_keys:
self.added_hint_types[dist] = []
for loc in self.hint_dist_user['add_locations']:
if 'types' in loc:
if dist in loc['types']:
self.added_hint_types[dist].append(loc['location'])
self.item_added_hint_types[dist] = []
for i in self.hint_dist_user['add_items']:
if dist in i['types']:
self.item_added_hint_types[dist].append(i['item'])
self.hint_type_overrides[dist] = []
for loc in self.hint_dist_user['remove_locations']:
if dist in loc['types']:
self.hint_type_overrides[dist].append(loc['location'])
self.item_hint_type_overrides[dist] = []
for i in self.hint_dist_user['remove_items']:
if dist in i['types']:
self.item_hint_type_overrides[dist].append(i['item'])
# Make empty dungeons non-hintable as barren dungeons
if settings.empty_dungeons_mode != 'none':
for info in self.empty_dungeons.values():
if info.empty:
self.hint_type_overrides['barren'].append(str(info.hint_name))
self.hint_text_overrides: dict[str, str] = {}
for loc in self.hint_dist_user['add_locations']:
if 'text' in loc:
# Arbitrarily throw an error at 80 characters to prevent overfilling the text box.
if len(loc['text']) > 80:
raise Exception('Custom hint text too large for %s', loc['location'])
self.hint_text_overrides.update({loc['location']: loc['text']})
self.item_hints: list[str] = self.settings.item_hints + self.item_added_hint_types["named-item"]
self.named_item_pool: list[str] = list(self.item_hints)
self.always_hints: list[str] = [hint.name for hint in get_required_hints(self)]
self.dungeon_rewards_hinted: bool = settings.shuffle_mapcompass != 'remove' if settings.enhance_map_compass else 'altar' in settings.misc_hints
self.misc_hint_items: dict[str, str] = {hint_type: self.hint_dist_user.get('misc_hint_items', {}).get(hint_type, data['default_item']) for hint_type, data in misc_item_hint_table.items()}
self.misc_hint_locations: dict[str, str] = {hint_type: self.hint_dist_user.get('misc_hint_locations', {}).get(hint_type, data['item_location']) for hint_type, data in misc_location_hint_table.items()}
self.state: State = State(self)
# Allows us to cut down on checking whether some items are required
self.max_progressions: dict[str, int] = {name: item.special.get('progressive', 1) for name, item in ItemInfo.items.items()}
max_tokens = 0
if self.settings.bridge == 'tokens':
max_tokens = max(max_tokens, self.settings.bridge_tokens)
if self.settings.lacs_condition == 'tokens':
max_tokens = max(max_tokens, self.settings.lacs_tokens)
if self.settings.shuffle_ganon_bosskey == 'tokens':
max_tokens = max(max_tokens, self.settings.ganon_bosskey_tokens)
tokens = [50, 40, 30, 20, 10]
for t in tokens:
if f'Kak {t} Gold Skulltula Reward' not in self.settings.disabled_locations:
max_tokens = max(max_tokens, t)
break
self.max_progressions['Gold Skulltula Token'] = max_tokens
max_hearts = 0
if self.settings.bridge == 'hearts':
max_hearts = max(max_hearts, self.settings.bridge_hearts)
if self.settings.lacs_condition == 'hearts':
max_hearts = max(max_hearts, self.settings.lacs_hearts)
if self.settings.shuffle_ganon_bosskey == 'hearts':
max_hearts = max(max_hearts, self.settings.ganon_bosskey_hearts)
self.max_progressions['Heart Container'] = max_hearts
self.max_progressions['Piece of Heart'] = max_hearts * 4
self.max_progressions['Piece of Heart (Treasure Chest Game)'] = max_hearts * 4
# Additional Ruto's Letter become Bottle, so we may have to collect two.
self.max_progressions['Rutos Letter'] = 2
# Available Gold Skulltula Tokens in world. Set to proper value in ItemPool.py.
self.available_tokens: int = 100
# Disable goal hints if the hint distro does not require them.
# WOTH locations are always searched.
self.enable_goal_hints: bool = False
if ('distribution' in self.hint_dist_user and
'goal' in self.hint_dist_user['distribution'] and
(self.hint_dist_user['distribution']['goal']['fixed'] != 0 or
self.hint_dist_user['distribution']['goal']['weight'] != 0)):
self.enable_goal_hints = True
# Initialize default goals for win condition
self.goal_categories: dict[str, GoalCategory] = OrderedDict()
if self.hint_dist_user['use_default_goals']:
self.set_goals()
# import goals from hint plando
if 'custom_goals' in self.hint_dist_user:
for category in self.hint_dist_user['custom_goals']:
if category['category'] in self.goal_categories:
cat = self.goal_categories[category['category']]
else:
cat = GoalCategory(category['category'], category['priority'], minimum_goals=category['minimum_goals'])
for goal in category['goals']:
cat.add_goal(Goal(self, goal['name'], goal['hint_text'], goal['color'], items=list({'name': i['name'], 'quantity': i['quantity'], 'minimum': i['minimum'], 'hintable': i['hintable']} for i in goal['items'])))
if 'count_override' in category:
cat.goal_count = category['count_override']
else:
cat.goal_count = len(cat.goals)
if 'lock_entrances' in category:
cat.lock_entrances = list(category['lock_entrances'])
self.goal_categories[cat.name] = cat
# Sort goal hint categories by priority
# For most settings this will be Bridge, GBK
self.goal_categories = OrderedDict({name: category for (name, category) in sorted(self.goal_categories.items(), key=lambda kv: kv[1].priority)})
# Turn on one hint per goal if all goal categories contain the same goals.
# Reduces the changes of randomly choosing one smaller category over and
# over again after the first round through the categories.
if len(self.goal_categories) > 0:
self.one_hint_per_goal = True
minor_goal_categories = ('door_of_time', 'ganon')
goal_list1 = []
for category in self.goal_categories.values():
if category.name not in minor_goal_categories:
goal_list1 = [goal.name for goal in category.goals]
for category in self.goal_categories.values():
if goal_list1 != [goal.name for goal in category.goals] and category.name not in minor_goal_categories:
self.one_hint_per_goal = False
# initialize category check for first rounds of goal hints
self.hinted_categories = []
# Quick item lookup for All Goals Reachable setting
self.goal_items = []
for cat_name, category in self.goal_categories.items():
for goal in category.goals:
for item in goal.items:
self.goal_items.append(item['name'])
# Separate goal categories into locked and unlocked for search optimization
self.locked_goal_categories: dict[str, GoalCategory] = {name: category for (name, category) in self.goal_categories.items() if category.lock_entrances}
self.unlocked_goal_categories: dict[str, GoalCategory] = {name: category for (name, category) in self.goal_categories.items() if not category.lock_entrances}
def copy(self) -> World:
new_world = World(self.id, self.settings, False)
new_world.skipped_trials = copy.copy(self.skipped_trials)
new_world.dungeon_mq = copy.copy(self.dungeon_mq)
new_world.empty_dungeons = copy.copy(self.empty_dungeons)
new_world.shop_prices = copy.copy(self.shop_prices)
new_world.triforce_goal = self.triforce_goal
new_world.triforce_count = self.triforce_count
new_world.total_starting_triforce_count = self.total_starting_triforce_count
new_world.maximum_wallets = self.maximum_wallets
new_world.distribution = self.distribution
new_world.dungeons = [dungeon for dungeon in self.dungeons]
new_world.regions = [region for region in self.regions]
new_world.itempool = [item for item in self.itempool]
new_world.state = self.state.copy(new_world)
# TODO: Why is this necessary over copying region.entrances on region copy?
# new_world.initialize_entrances()
# copy any randomized settings to match the original copy
new_world.randomized_list = list(self.randomized_list)
for randomized_item in new_world.randomized_list:
setattr(new_world, randomized_item, getattr(self.settings, randomized_item))
new_world.always_hints = list(self.always_hints)
new_world.max_progressions = copy.copy(self.max_progressions)
new_world.available_tokens = self.available_tokens
new_world.song_notes = copy.copy(self.song_notes)
return new_world
def set_random_bridge_values(self) -> None:
if self.settings.bridge == 'medallions':
self.settings.bridge_medallions = 6
self.randomized_list.append('bridge_medallions')
if self.settings.bridge == 'dungeons':
self.settings.bridge_rewards = 9
self.randomized_list.append('bridge_rewards')
if self.settings.bridge == 'stones':
self.settings.bridge_stones = 3
self.randomized_list.append('bridge_stones')
def resolve_random_settings(self) -> None:
# evaluate settings (important for logic, nice for spoiler)
self.randomized_list = []
dist_keys = set()
if '_settings' in self.distribution.distribution.src_dict:
dist_keys = self.distribution.distribution.src_dict['_settings'].keys()
if self.settings.randomize_settings:
setting_info = SettingInfos.setting_infos['randomize_settings']
self.randomized_list.extend(setting_info.disable[True]['settings'])
for section in setting_info.disable[True]['sections']:
self.randomized_list.extend(get_settings_from_section(section))
# Remove settings specified in the distribution
self.randomized_list = [x for x in self.randomized_list if x not in dist_keys]
for setting in list(self.randomized_list):
if (setting == 'bridge_medallions' and self.settings.bridge != 'medallions') \
or (setting == 'bridge_stones' and self.settings.bridge != 'stones') \
or (setting == 'bridge_rewards' and self.settings.bridge != 'dungeons') \
or (setting == 'bridge_tokens' and self.settings.bridge != 'tokens') \
or (setting == 'bridge_hearts' and self.settings.bridge != 'hearts') \
or (setting == 'lacs_medallions' and self.settings.lacs_condition != 'medallions') \
or (setting == 'lacs_stones' and self.settings.lacs_condition != 'stones') \
or (setting == 'lacs_rewards' and self.settings.lacs_condition != 'dungeons') \
or (setting == 'lacs_tokens' and self.settings.lacs_condition != 'tokens') \
or (setting == 'lacs_hearts' and self.settings.lacs_condition != 'hearts') \
or (setting == 'ganon_bosskey_medallions' and self.settings.shuffle_ganon_bosskey != 'medallions') \
or (setting == 'ganon_bosskey_stones' and self.settings.shuffle_ganon_bosskey != 'stones') \
or (setting == 'ganon_bosskey_rewards' and self.settings.shuffle_ganon_bosskey != 'dungeons') \
or (setting == 'ganon_bosskey_tokens' and self.settings.shuffle_ganon_bosskey != 'tokens') \
or (setting == 'ganon_bosskey_hearts' and self.settings.shuffle_ganon_bosskey != 'hearts'):
self.randomized_list.remove(setting)
if self.settings.big_poe_count_random and 'big_poe_count' not in dist_keys:
self.settings.big_poe_count = random.randint(1, 10)
self.randomized_list.append('big_poe_count')
# If set to random in GUI, we don't want to randomize if it was specified as non-random in the distribution
if (self.settings.starting_tod == 'random'
and ('starting_tod' not in dist_keys
or self.distribution.distribution.src_dict['_settings']['starting_tod'] == 'random')):
setting_info = SettingInfos.setting_infos['starting_tod']
choices = [ch for ch in setting_info.choices if ch not in ('default', 'random')]
self.settings.starting_tod = random.choice(choices)
self.randomized_list.append('starting_tod')
if (self.settings.starting_age == 'random'
and ('starting_age' not in dist_keys
or self.distribution.distribution.src_dict['_settings']['starting_age'] == 'random')):
if self.settings.open_forest == 'closed':
# adult is not compatible
self.settings.starting_age = 'child'
else:
self.settings.starting_age = random.choice(['child', 'adult'])
self.randomized_list.append('starting_age')
if self.settings.chicken_count_random and 'chicken_count' not in dist_keys:
self.settings.chicken_count = random.randint(0, 7)
self.randomized_list.append('chicken_count')
# Determine dungeons with shortcuts
dungeons = ['Deku Tree', 'Dodongos Cavern', 'Jabu Jabus Belly', 'Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple']
if self.settings.dungeon_shortcuts_choice == 'random':
self.settings.dungeon_shortcuts = random.sample(dungeons, random.randint(0, len(dungeons)))
self.randomized_list.append('dungeon_shortcuts')
elif self.settings.dungeon_shortcuts_choice == 'all':
self.settings.dungeon_shortcuts = dungeons
# Determine areas with keyrings
areas = ['Thieves Hideout', 'Treasure Chest Game', 'Forest Temple', 'Fire Temple', 'Water Temple', 'Shadow Temple', 'Spirit Temple', 'Bottom of the Well', 'Gerudo Training Ground', 'Ganons Castle']
if self.settings.key_rings_choice == 'random':
self.settings.key_rings = random.sample(areas, random.randint(0, len(areas)))
self.randomized_list.append('key_rings')
elif self.settings.key_rings_choice == 'all':
self.settings.key_rings = areas
# Handle random Rainbow Bridge condition
if (self.settings.bridge == 'random'
and ('bridge' not in dist_keys
or self.distribution.distribution.src_dict['_settings']['bridge'] == 'random')):
possible_bridge_requirements = ["open", "medallions", "dungeons", "stones", "vanilla"]
self.settings.bridge = random.choice(possible_bridge_requirements)
self.set_random_bridge_values()
self.randomized_list.append('bridge')
# Determine Ganon Trials
trial_pool = list(self.skipped_trials)
dist_chosen = self.distribution.configure_trials(trial_pool)
dist_num_chosen = len(dist_chosen)
if self.settings.trials_random and 'trials' not in dist_keys:
self.settings.trials = dist_num_chosen + random.randint(0, len(trial_pool))
self.randomized_list.append('trials')
num_trials = int(self.settings.trials)
if num_trials < dist_num_chosen:
raise RuntimeError("%d trials set to active on world %d, but only %d active trials allowed." % (dist_num_chosen, self.id, num_trials))
chosen_trials = random.sample(trial_pool, num_trials - dist_num_chosen)
for trial in self.skipped_trials:
if trial not in chosen_trials and trial not in dist_chosen:
self.skipped_trials[trial] = True
# Determine empty and MQ Dungeons (avoid having both empty & MQ dungeons unless necessary)
mq_dungeon_pool = list(self.dungeon_mq)
empty_dungeon_pool = list(self.empty_dungeons)
dist_num_mq, dist_num_empty = self.distribution.configure_dungeons(self, mq_dungeon_pool, empty_dungeon_pool)
if self.settings.empty_dungeons_mode == 'specific':
for dung in self.settings.empty_dungeons_specific:
self.empty_dungeons[dung].empty = True
if self.settings.mq_dungeons_mode == 'specific':
for dung in self.settings.mq_dungeons_specific:
self.dungeon_mq[dung] = True
if self.settings.empty_dungeons_mode == 'count':
nb_to_pick = self.settings.empty_dungeons_count - dist_num_empty
if nb_to_pick < 0:
raise RuntimeError(f"{dist_num_empty} dungeons are set to empty on world {self.id+1}, but only {self.settings.empty_dungeons_count} empty dungeons allowed")
if len(empty_dungeon_pool) < nb_to_pick:
non_empty = 8 - dist_num_empty - len(empty_dungeon_pool)
raise RuntimeError(f"On world {self.id+1}, {dist_num_empty} dungeons are set to empty and {non_empty} to non-empty. Can't reach {self.settings.empty_dungeons_count} empty dungeons.")
# Prioritize non-MQ dungeons
non_mq, mq = [], []
for dung in empty_dungeon_pool:
(mq if self.dungeon_mq[dung] else non_mq).append(dung)
for dung in random.sample(non_mq, min(nb_to_pick, len(non_mq))):
self.empty_dungeons[dung].empty = True
nb_to_pick -= 1
if nb_to_pick > 0:
for dung in random.sample(mq, nb_to_pick):
self.empty_dungeons[dung].empty = True
if self.settings.mq_dungeons_mode == 'random' and 'mq_dungeons_count' not in dist_keys:
for dungeon in mq_dungeon_pool:
self.dungeon_mq[dungeon] = random.choice([True, False])
self.randomized_list.append('mq_dungeons_count')
elif self.settings.mq_dungeons_mode in ('mq', 'vanilla'):
for dung in self.dungeon_mq.keys():
self.dungeon_mq[dung] = (self.settings.mq_dungeons_mode == 'mq')
elif self.settings.mq_dungeons_mode != 'specific':
nb_to_pick = self.settings.mq_dungeons_count - dist_num_mq
if nb_to_pick < 0:
raise RuntimeError("%d dungeons are set to MQ on world %d, but only %d MQ dungeons allowed." % (dist_num_mq, self.id+1, self.settings.mq_dungeons_count))
if len(mq_dungeon_pool) < nb_to_pick:
non_mq = 8 - dist_num_mq - len(mq_dungeon_pool)
raise RuntimeError(f"On world {self.id+1}, {dist_num_mq} dungeons are set to MQ and {non_mq} to non-MQ. Can't reach {self.settings.mq_dungeons_count} MQ dungeons.")
# Prioritize non-empty dungeons
non_empty, empty = [], []
for dung in mq_dungeon_pool:
(empty if self.empty_dungeons[dung].empty else non_empty).append(dung)
for dung in random.sample(non_empty, min(nb_to_pick, len(non_empty))):
self.dungeon_mq[dung] = True
nb_to_pick -= 1
if nb_to_pick > 0:
for dung in random.sample(empty, nb_to_pick):
self.dungeon_mq[dung] = True
self.settings.mq_dungeons_count = list(self.dungeon_mq.values()).count(True)
self.distribution.configure_randomized_settings(self)
# Determine puzzles with silver rupee pouches
if self.settings.silver_rupee_pouches_choice == 'random':
puzzles = self.silver_rupee_puzzles()
self.settings.silver_rupee_pouches = random.sample(puzzles, random.randint(0, len(puzzles)))
self.randomized_list.append('silver_rupee_pouches')
elif self.settings.silver_rupee_pouches_choice == 'all':
self.settings.silver_rupee_pouches = self.silver_rupee_puzzles()
def load_regions_from_json(self, file_path: str) -> list[tuple[Entrance, str]]:
region_json = read_logic_file(file_path)
savewarps_to_connect = []
for region in region_json:
new_region = Region(self, region['region_name'])
if 'scene' in region:
new_region.scene = region['scene']
if 'hint' in region:
new_region.hint_name = region['hint']
if 'alt_hint' in region:
new_region.alt_hint_name = region['alt_hint']
if 'dungeon' in region:
new_region.dungeon_name = region['dungeon']
if 'is_boss_room' in region:
new_region.is_boss_room = region['is_boss_room']
if 'time_passes' in region:
new_region.time_passes = region['time_passes']
new_region.provides_time = TimeOfDay.ALL
if new_region.name in ['Ganons Castle Grounds', 'Ganons Castle Ledge']:
new_region.provides_time = TimeOfDay.DAMPE
if 'locations' in region:
for location, rule in region['locations'].items():
new_location = LocationFactory(location)
new_location.parent_region = new_region
new_location.rule_string = rule
if self.settings.logic_rules != 'none':
self.parser.parse_spot_rule(new_location)
if new_location.never:
# We still need to fill the location even if ALR is off.
logging.getLogger('').debug('Unreachable location: %s', new_location.name)
new_location.world = self
new_region.locations.append(new_location)
if 'events' in region:
for event, rule in region['events'].items():
# Allow duplicate placement of events
lname = '%s from %s' % (event, new_region.name)
new_location = Location(lname, location_type='Event', parent=new_region)
new_location.rule_string = rule
if self.settings.logic_rules != 'none':
self.parser.parse_spot_rule(new_location)
if new_location.never:
logging.getLogger('').debug('Dropping unreachable event: %s', new_location.name)
else:
new_location.world = self
new_region.locations.append(new_location)
make_event_item(event, new_location)
if 'exits' in region:
for exit, rule in region['exits'].items():
new_exit = Entrance('%s -> %s' % (new_region.name, exit), new_region)
new_exit.connected_region = exit
new_exit.rule_string = rule
if self.settings.logic_rules != 'none':
self.parser.parse_spot_rule(new_exit)
new_region.exits.append(new_exit)
if 'savewarp' in region:
savewarp_target = region['savewarp'].split(' -> ')[1]
new_exit = Entrance(f'{new_region.name} -> {savewarp_target}', new_region)
new_exit.connected_region = savewarp_target
new_region.exits.append(new_exit)
new_region.savewarp = new_exit
# the replaced entrance may not exist yet so we connect it after all region files have been read
savewarps_to_connect.append((new_exit, region['savewarp']))
self.regions.append(new_region)
return savewarps_to_connect
def create_dungeons(self) -> list[tuple[Entrance, str]]:
savewarps_to_connect = []
for hint_area in HintArea:
if (name := hint_area.dungeon_name) is not None:
logic_folder = 'Glitched World' if self.settings.logic_rules == 'glitched' else 'World'
file_name = name + (' MQ.json' if self.dungeon_mq[name] else '.json')
savewarps_to_connect += self.load_regions_from_json(os.path.join(data_path(logic_folder), file_name))
self.dungeons.append(Dungeon(self, name, hint_area))
return savewarps_to_connect
def create_internal_locations(self) -> None:
self.parser.create_delayed_rules()
assert self.parser.events <= self.event_items, 'Parse error: undefined items %r' % (self.parser.events - self.event_items)
def initialize_entrances(self) -> None:
for region in self.regions:
for exit in region.exits:
if exit.connected_region:
exit.connect(self.get_region(exit.connected_region))
exit.world = self
def initialize_regions(self) -> None:
for region in self.regions:
region.world = self
for location in region.locations:
location.world = self
def initialize_items(self, items: Optional[list[Item]] = None) -> None:
items = self.itempool if items is None else items
item_dict = defaultdict(list)
for item in items:
item_dict[item.name].append(item)
if (self.settings.shuffle_hideoutkeys in ('fortress', 'regional') and item.type == 'HideoutSmallKey') or (self.settings.shuffle_tcgkeys == 'regional' and item.type == 'TCGSmallKey'):
item.priority = True
for dungeon in self.dungeons:
dungeon_items = [item for item_name in dungeon.get_item_names() for item in item_dict[item_name]]
for item in dungeon_items:
shuffle_setting = None
dungeon_collection = None
if item.map or item.compass:
dungeon_collection = dungeon.dungeon_items
shuffle_setting = self.settings.shuffle_mapcompass
elif item.smallkey:
dungeon_collection = dungeon.small_keys
shuffle_setting = self.settings.shuffle_smallkeys
elif item.bosskey:
dungeon_collection = dungeon.boss_key
shuffle_setting = self.settings.shuffle_bosskeys
elif item.type == 'SilverRupee':
dungeon_collection = dungeon.silver_rupees
shuffle_setting = self.settings.shuffle_silver_rupees
elif item.type == 'DungeonReward':
dungeon_collection = dungeon.reward
shuffle_setting = self.settings.shuffle_dungeon_rewards
if dungeon_collection is not None and item not in dungeon_collection:
dungeon_collection.append(item)
if shuffle_setting in ('any_dungeon', 'overworld', 'regional'):
item.priority = True
def random_shop_prices(self) -> None:
shop_item_indexes = ['7', '5', '8', '6']
self.shop_prices = {}
for region in self.regions:
if self.settings.shopsanity == 'random':
shop_item_count = random.randint(0, 4)
else:
shop_item_count = int(self.settings.shopsanity)
for location in region.locations:
if location.type == 'Shop':
if location.name[-1:] in shop_item_indexes[:shop_item_count]:
if self.settings.shopsanity_prices == 'random':
self.shop_prices[location.name] = int(random.betavariate(1.5, 2) * 60) * 5
elif self.settings.shopsanity_prices == 'random_starting':
self.shop_prices[location.name] = random.randrange(0, 100, 5)
elif self.settings.shopsanity_prices == 'random_adult':
self.shop_prices[location.name] = random.randrange(0, 201, 5)
elif self.settings.shopsanity_prices == 'random_giant':
self.shop_prices[location.name] = random.randrange(0, 501, 5)
elif self.settings.shopsanity_prices == 'random_tycoon':
self.shop_prices[location.name] = random.randrange(0, 1000, 5)
elif self.settings.shopsanity_prices == 'affordable':
self.shop_prices[location.name] = 10
def set_scrub_prices(self) -> None:
# Get Deku Scrub Locations
scrub_locations = [location for location in self.get_locations() if location.type in ('Scrub', 'GrottoScrub')]
scrub_dictionary = {}
for location in scrub_locations:
if location.default not in scrub_dictionary:
scrub_dictionary[location.default] = []
scrub_dictionary[location.default].append(location)
# Loop through each type of scrub.
for (scrub_item, default_price, text_id, text_replacement) in business_scrubs:
price = default_price
if self.settings.shuffle_scrubs == 'low':
price = 10
elif self.settings.shuffle_scrubs == 'random':
# this is a random value between 0-99
# average value is ~33 rupees
price = int(random.betavariate(1, 2) * 99)
# Set price in the dictionary as well as the location.
self.scrub_prices[scrub_item] = price
if scrub_item in scrub_dictionary:
for location in scrub_dictionary[scrub_item]:
location.price = price
if location.item is not None:
location.item.price = price
def fill_bosses(self, boss_count: int = 9) -> None:
boss_rewards = ItemFactory(reward_list, self)
boss_locations = [self.get_location(loc) for loc in location_groups['Boss']]
placed_prizes = [loc.item.name for loc in boss_locations if loc.item is not None]
unplaced_prizes = [item for item in boss_rewards if item.name not in placed_prizes]
empty_boss_locations = [loc for loc in boss_locations if loc.item is None]
prizepool = list(unplaced_prizes)
prize_locs = list(empty_boss_locations)
boss_count -= self.distribution.fill_bosses(self, prize_locs, prizepool)
while boss_count:
boss_count -= 1
random.shuffle(prizepool)
random.shuffle(prize_locs)
loc = prize_locs.pop()
if self.settings.shuffle_dungeon_rewards == 'vanilla':
item = next(item for item in prizepool if item.name == location_table[loc.name][4])
elif self.settings.shuffle_dungeon_rewards == 'reward':
item = prizepool.pop()
self.push_item(loc, item)
def set_empty_dungeon_rewards(self, empty_rewards: list[str] = []) -> None:
empty_dungeon_bosses = list(map(lambda reward: self.find_items(reward)[0].name, empty_rewards))
for boss in empty_dungeon_bosses:
for dungeon_item in self.empty_dungeons.items():
if dungeon_item[1].boss_name == boss:
dungeon_item[1].empty = True
self.hint_type_overrides['barren'].append(dungeon_item[1].hint_name)
def set_goals(self) -> None:
# Default goals are divided into 3 primary categories:
# Bridge, Ganon's Boss Key, and Trials
# The Triforce Hunt goal is mutually exclusive with
# these categories given the vastly different playstyle.
#
# Goal priorities determine where hintable locations are placed.
# For example, an item required for both trials and bridge would
# be hinted only for bridge. This accomplishes two objectives:
# 1) Locations are not double counted for different stages
# of the game
# 2) Later category location lists are not diluted by early
# to mid game locations
#
# Entrance locks set restrictions on all goals in a category to
# ensure unreachable goals are not hintable. This is only used
# for the Rainbow Bridge to filter out goals hard-locked by
# Inside Ganon's Castle access.
#
# Minimum goals for a category tell the randomizer if the
# category meta-goal is satisfied by starting items. This
# is straightforward for dungeon reward goals where X rewards
# is the same as the minimum goals. For Triforce Hunt, Trials,
# and Skull conditions, there is only one goal in the category
# requesting X copies within the goal, so minimum goals has to
# be 1 for these.
dot = GoalCategory('door_of_time', 5, lock_entrances=['Temple of Time -> Beyond Door of Time'], minimum_goals=1)
b = GoalCategory('rainbow_bridge', 10, lock_entrances=['Ganons Castle Ledge -> Ganons Castle Lobby'])
gbk = GoalCategory('ganon_bosskey', 20)
trials = GoalCategory('trials', 30, minimum_goals=1)
th = GoalCategory('triforce_hunt', 30, goal_count=round(self.settings.triforce_goal_per_world / 10), minimum_goals=1)
ganon = GoalCategory('ganon', 40, goal_count=1)
trial_goal = Goal(self, 'the Tower', 'path to #the Tower#', 'White', items=[], create_empty=True)
if self.settings.triforce_hunt and self.settings.triforce_goal_per_world > 0:
# "Hintable" value of False means the goal items themselves cannot
# be hinted directly. This is used for Triforce Hunt and Skull
# conditions to restrict hints to useful items instead of the win
# condition. Dungeon rewards do not need this restriction as they are
# already unhintable at a lower level.
#
# This restriction does NOT apply to Light Arrows or Ganon's Castle Boss
# Key, which makes these items directly hintable in their respective goals
# assuming they do not get hinted by another hint type (always, woth with
# an earlier order in the hint distro, etc).
th.add_goal(Goal(self, 'gold', 'path of #gold#', 'Yellow', items=[{'name': 'Triforce Piece', 'quantity': self.settings.triforce_count_per_world, 'minimum': self.settings.triforce_goal_per_world, 'hintable': False}]))
self.goal_categories[th.name] = th
# Category goals are defined for each possible setting for each category.
# Bridge can be Stones, Medallions, Dungeons, Skulls, or Vanilla.
# Ganon's Boss Key can be Stones, Medallions, Dungeons, Skulls, LACS or
# one of the keysanity variants.
# Trials is one goal that is only on if at least one trial is on in the world.
# If there are no trials, a "path of the hero" clone of WOTH is created, which
# is deprioritized compared to other goals. Path wording is used to distinguish
# the hint type even though the hintable location set is identical to WOTH.
if not self.settings.triforce_hunt:
if self.settings.starting_age == 'child':
dot_items = [{'name': 'Temple of Time Access', 'quantity': 1, 'minimum': 1, 'hintable': True}]
if not self.settings.open_door_of_time:
dot_items.append({'name': 'Song of Time', 'quantity': 2 if self.settings.shuffle_song_items == 'any' and self.settings.item_pool_value == 'plentiful' else 1, 'minimum': 1, 'hintable': True})
if self.settings.shuffle_ocarinas:
dot_items.append({'name': 'Ocarina', 'quantity': 3 if self.settings.item_pool_value == 'plentiful' else 2, 'minimum': 1, 'hintable': True})
dot.add_goal(Goal(self, 'Door of Time', 'path of #time#', 'Light Blue', items=dot_items))
self.goal_categories[dot.name] = dot
# Bridge goals will always be defined as they have the most immediate priority
if self.settings.bridge != 'open' and not self.shuffle_special_dungeon_entrances:
# "Replace" hint text dictionaries are used to reference the
# dungeon boss holding the specified reward. Only boss names/paths
# are defined for this feature, and it is not extendable via plando.
# Goal hint text colors are based on the dungeon reward, not the boss.
if (self.settings.bridge_stones > 0 and self.settings.bridge == 'stones') or (self.settings.bridge_rewards > 0 and self.settings.bridge == 'dungeons'):
b.add_goal(Goal(self, 'Kokiri Emerald', { 'replace': 'Kokiri Emerald' }, 'Green', items=[{'name': 'Kokiri Emerald', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Goron Ruby', { 'replace': 'Goron Ruby' }, 'Red', items=[{'name': 'Goron Ruby', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Zora Sapphire', { 'replace': 'Zora Sapphire' }, 'Blue', items=[{'name': 'Zora Sapphire', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.minimum_goals = self.settings.bridge_stones if self.settings.bridge == 'stones' else self.settings.bridge_rewards
if (self.settings.bridge_medallions > 0 and self.settings.bridge == 'medallions') or (self.settings.bridge_rewards > 0 and self.settings.bridge == 'dungeons'):
b.add_goal(Goal(self, 'Forest Medallion', { 'replace': 'Forest Medallion' }, 'Green', items=[{'name': 'Forest Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Fire Medallion', { 'replace': 'Fire Medallion' }, 'Red', items=[{'name': 'Fire Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Water Medallion', { 'replace': 'Water Medallion' }, 'Blue', items=[{'name': 'Water Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Shadow Medallion', { 'replace': 'Shadow Medallion' }, 'Pink', items=[{'name': 'Shadow Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Spirit Medallion', { 'replace': 'Spirit Medallion' }, 'Yellow', items=[{'name': 'Spirit Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Light Medallion', { 'replace': 'Light Medallion' }, 'Light Blue', items=[{'name': 'Light Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.minimum_goals = self.settings.bridge_medallions if self.settings.bridge == 'medallions' else self.settings.bridge_rewards
if self.settings.bridge == 'vanilla':
b.add_goal(Goal(self, 'Shadow Medallion', { 'replace': 'Shadow Medallion' }, 'Pink', items=[{'name': 'Shadow Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
b.add_goal(Goal(self, 'Spirit Medallion', { 'replace': 'Spirit Medallion' }, 'Yellow', items=[{'name': 'Spirit Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
min_goals = 2
# With plentiful item pool, multiple copies of Light Arrows are available,
# but both are not guaranteed reachable. Setting a goal quantity of the
# item pool value with a minimum quantity of 1 attempts to hint all items
# required to get all copies of Light Arrows, but will fall back to just
# one copy if the other is unreachable.
#
# Similar criteria is used for Ganon's Boss Key in plentiful keysanity.
if not 'Light Arrows' in self.item_added_hint_types['always']:
if self.settings.item_pool_value == 'plentiful':
arrows = 2
else:
arrows = 1
b.add_goal(Goal(self, 'Evil\'s Bane', 'path to #Evil\'s Bane#', 'Light Blue', items=[{'name': 'Light Arrows', 'quantity': arrows, 'minimum': 1, 'hintable': True}]))
min_goals += 1
b.minimum_goals = min_goals
# Goal count within a category is currently unused. Testing is in progress
# to potentially use this for weighting certain goals for hint selection.
b.goal_count = len(b.goals)
if (self.settings.bridge_tokens > 0
and self.settings.bridge == 'tokens'
and (self.settings.shuffle_ganon_bosskey != 'tokens'
or self.settings.bridge_tokens >= self.settings.ganon_bosskey_tokens)
and (self.settings.shuffle_ganon_bosskey != 'on_lacs' or self.settings.lacs_condition != 'tokens'
or self.settings.bridge_tokens >= self.settings.lacs_tokens)):
b.add_goal(Goal(self, 'Skulls', 'path of #Skulls#', 'Pink', items=[{'name': 'Gold Skulltula Token', 'quantity': 100, 'minimum': self.settings.bridge_tokens, 'hintable': False}]))
b.goal_count = round(self.settings.bridge_tokens / 10)
b.minimum_goals = 1
if (self.settings.bridge_hearts > self.settings.starting_hearts
and self.settings.bridge == 'hearts'
and (self.settings.shuffle_ganon_bosskey != 'hearts'
or self.settings.bridge_hearts >= self.settings.ganon_bosskey_hearts)
and (self.settings.shuffle_ganon_bosskey != 'on_lacs' or self.settings.lacs_condition != 'hearts'
or self.settings.bridge_hearts >= self.settings.lacs_hearts)):
b.add_goal(Goal(self, 'hearts', 'path of #hearts#', 'Red', items=[{'name': 'Piece of Heart', 'quantity': (20 - self.settings.starting_hearts) * 4, 'minimum': (self.settings.bridge_hearts - self.settings.starting_hearts) * 4, 'hintable': False}]))
b.goal_count = round((self.settings.bridge_hearts - 3) / 2)
b.minimum_goals = 1
self.goal_categories[b.name] = b
# If the Ganon's Boss Key condition is the same or similar conditions
# as Bridge, do not create the goals if Bridge goals already cover
# GBK goals. For example, 3 dungeon GBK would not have its own goals
# if it is 4 medallion bridge.
#
# Even if created, there is no guarantee GBK goals will find new
# locations to hint. If duplicate goals are defined for Bridge and
# all of these goals are accessible without Ganon's Castle access,
# the GBK category is redundant and not used for hint selection.
if ((self.settings.ganon_bosskey_stones > 0
and self.settings.shuffle_ganon_bosskey == 'stones'
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_stones > self.settings.bridge_stones or self.settings.bridge != 'stones'))
or (self.settings.lacs_stones > 0
and self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'stones'
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_stones > self.settings.bridge_stones or self.settings.bridge != 'stones'))
or (self.settings.ganon_bosskey_rewards > 0
and self.settings.shuffle_ganon_bosskey == 'dungeons'
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > self.settings.bridge_medallions or self.settings.bridge != 'medallions')
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > self.settings.bridge_stones or self.settings.bridge != 'stones')
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > self.settings.bridge_rewards or self.settings.bridge != 'dungeons')
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > 2 or self.settings.bridge != 'vanilla'))
or (self.settings.lacs_rewards > 0
and self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'dungeons'
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > self.settings.bridge_medallions or self.settings.bridge != 'medallions')
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > self.settings.bridge_stones or self.settings.bridge != 'stones')
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > self.settings.bridge_rewards or self.settings.bridge != 'dungeons')
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > 2 or self.settings.bridge != 'vanilla'))):
gbk.add_goal(Goal(self, 'Kokiri Emerald', { 'replace': 'Kokiri Emerald' }, 'Green', items=[{'name': 'Kokiri Emerald', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Goron Ruby', { 'replace': 'Goron Ruby' }, 'Red', items=[{'name': 'Goron Ruby', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Zora Sapphire', { 'replace': 'Zora Sapphire' }, 'Blue', items=[{'name': 'Zora Sapphire', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.minimum_goals = (self.settings.ganon_bosskey_stones if self.settings.shuffle_ganon_bosskey == 'stones'
else self.settings.lacs_stones if self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'stones'
else self.settings.ganon_bosskey_rewards if self.settings.shuffle_ganon_bosskey == 'dungeons'
else self.settings.lacs_rewards)
if ((self.settings.ganon_bosskey_medallions > 0
and self.settings.shuffle_ganon_bosskey == 'medallions'
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_medallions > self.settings.bridge_medallions or self.settings.bridge != 'medallions')
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_medallions > 2 or self.settings.bridge != 'vanilla'))
or (self.settings.lacs_medallions > 0
and self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'medallions'
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_medallions > self.settings.bridge_medallions or self.settings.bridge != 'medallions')
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_medallions > 2 or self.settings.bridge != 'vanilla'))
or (self.settings.ganon_bosskey_rewards > 0
and self.settings.shuffle_ganon_bosskey == 'dungeons'
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > self.settings.bridge_medallions or self.settings.bridge != 'medallions')
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > self.settings.bridge_stones or self.settings.bridge != 'stones')
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > self.settings.bridge_rewards or self.settings.bridge != 'dungeons')
and (self.shuffle_special_dungeon_entrances or self.settings.ganon_bosskey_rewards > 2 or self.settings.bridge != 'vanilla'))
or (self.settings.lacs_rewards > 0
and self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'dungeons'
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > self.settings.bridge_medallions or self.settings.bridge != 'medallions')
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > self.settings.bridge_stones or self.settings.bridge != 'stones')
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > self.settings.bridge_rewards or self.settings.bridge != 'dungeons')
and (self.shuffle_special_dungeon_entrances or self.settings.lacs_rewards > 2 or self.settings.bridge != 'vanilla'))):
gbk.add_goal(Goal(self, 'Forest Medallion', { 'replace': 'Forest Medallion' }, 'Green', items=[{'name': 'Forest Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Fire Medallion', { 'replace': 'Fire Medallion' }, 'Red', items=[{'name': 'Fire Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Water Medallion', { 'replace': 'Water Medallion' }, 'Blue', items=[{'name': 'Water Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Shadow Medallion', { 'replace': 'Shadow Medallion' }, 'Pink', items=[{'name': 'Shadow Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Spirit Medallion', { 'replace': 'Spirit Medallion' }, 'Yellow', items=[{'name': 'Spirit Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Light Medallion', { 'replace': 'Light Medallion' }, 'Light Blue', items=[{'name': 'Light Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.minimum_goals = (self.settings.ganon_bosskey_medallions if self.settings.shuffle_ganon_bosskey == 'medallions'
else self.settings.lacs_medallions if self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'medallions'
else self.settings.ganon_bosskey_rewards if self.settings.shuffle_ganon_bosskey == 'dungeons'
else self.settings.lacs_rewards)
if self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'vanilla':
gbk.add_goal(Goal(self, 'Shadow Medallion', { 'replace': 'Shadow Medallion' }, 'Pink', items=[{'name': 'Shadow Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.add_goal(Goal(self, 'Spirit Medallion', { 'replace': 'Spirit Medallion' }, 'Yellow', items=[{'name': 'Spirit Medallion', 'quantity': 1, 'minimum': 1, 'hintable': False}]))
gbk.minimum_goals = 2
gbk.goal_count = len(gbk.goals)
if (self.settings.ganon_bosskey_tokens > 0
and self.settings.shuffle_ganon_bosskey == 'tokens'
and (self.shuffle_special_dungeon_entrances
or self.settings.bridge != 'tokens'
or self.settings.bridge_tokens < self.settings.ganon_bosskey_tokens)):
gbk.add_goal(Goal(self, 'Skulls', 'path of #Skulls#', 'Pink', items=[{'name': 'Gold Skulltula Token', 'quantity': 100, 'minimum': self.settings.ganon_bosskey_tokens, 'hintable': False}]))
gbk.goal_count = round(self.settings.ganon_bosskey_tokens / 10)
gbk.minimum_goals = 1
if (self.settings.lacs_tokens > 0
and self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'tokens'
and (self.shuffle_special_dungeon_entrances
or self.settings.bridge != 'tokens'
or self.settings.bridge_tokens < self.settings.lacs_tokens)):
gbk.add_goal(Goal(self, 'Skulls', 'path of #Skulls#', 'Pink', items=[{'name': 'Gold Skulltula Token', 'quantity': 100, 'minimum': self.settings.lacs_tokens, 'hintable': False}]))
gbk.goal_count = round(self.settings.lacs_tokens / 10)
gbk.minimum_goals = 1
if (self.settings.ganon_bosskey_hearts > self.settings.starting_hearts
and self.settings.shuffle_ganon_bosskey == 'hearts'
and (self.shuffle_special_dungeon_entrances
or self.settings.bridge != 'hearts'
or self.settings.bridge_hearts < self.settings.ganon_bosskey_hearts)):
gbk.add_goal(Goal(self, 'hearts', 'path of #hearts#', 'Red', items=[{'name': 'Piece of Heart', 'quantity': (20 - self.settings.starting_hearts) * 4, 'minimum': (self.settings.ganon_bosskey_hearts - self.settings.starting_hearts) * 4, 'hintable': False}]))
gbk.goal_count = round((self.settings.ganon_bosskey_hearts - 3) / 2)
gbk.minimum_goals = 1
if (self.settings.lacs_hearts > self.settings.starting_hearts
and self.settings.shuffle_ganon_bosskey == 'on_lacs' and self.settings.lacs_condition == 'hearts'
and (self.shuffle_special_dungeon_entrances
or self.settings.bridge != 'hearts'
or self.settings.bridge_hearts < self.settings.lacs_hearts)):
gbk.add_goal(Goal(self, 'hearts', 'path of #hearts#', 'Red', items=[{'name': 'Piece of Heart', 'quantity': (20 - self.settings.starting_hearts) * 4, 'minimum': (self.settings.lacs_hearts - self.settings.starting_hearts) * 4, 'hintable': False}]))
gbk.goal_count = round((self.settings.lacs_hearts - 3) / 2)
gbk.minimum_goals = 1