forked from OoTRandomizer/OoT-Randomizer
-
Notifications
You must be signed in to change notification settings - Fork 0
/
World.py
518 lines (429 loc) · 21.3 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
from State import State
from Region import Region
from Entrance import Entrance
from Hints import get_hint_area
from Location import Location, LocationFactory
from LocationList import business_scrubs
from DungeonList import create_dungeons
from Rules import set_rules, set_shop_rules
from Item import Item
from RuleParser import parse_rule_string
from SettingsList import get_setting_info
import logging
import copy
import io
import json
import random
import re
class World(object):
def __init__(self, settings):
self.shuffle = 'vanilla'
self.dungeons = []
self.regions = []
self.itempool = []
self.state = State(self)
self._cached_locations = None
self._entrance_cache = {}
self._region_cache = {}
self._location_cache = {}
self.required_locations = []
self.shop_prices = {}
self.scrub_prices = {}
self.light_arrow_location = None
# dump settings directly into world's namespace
# this gives the world an attribute for every setting listed in Settings.py
self.settings = settings
self.__dict__.update(settings.__dict__)
self.distribution = None
# evaluate settings (important for logic, nice for spoiler)
if self.big_poe_count_random:
self.big_poe_count = random.randint(1, 10)
if self.starting_tod == 'random':
setting_info = get_setting_info('starting_tod')
choices = [ch for ch in setting_info.choices if ch not in ['default', 'random']]
self.starting_tod = random.choice(choices)
if self.starting_age == 'random':
self.starting_age = random.choice(['child', 'adult'])
# rename a few attributes...
self.keysanity = self.shuffle_smallkeys != 'dungeon'
self.check_beatable_only = not self.all_reachable
self.shuffle_dungeon_entrances = self.entrance_shuffle == 'dungeons' or self.entrance_shuffle == 'indoors'
self.shuffle_grotto_entrances = self.entrance_shuffle == 'indoors'
self.shuffle_interior_entrances = self.entrance_shuffle == 'indoors'
# trials that can be skipped will be decided later
self.skipped_trials = {
'Forest': False,
'Fire': False,
'Water': False,
'Spirit': False,
'Shadow': False,
'Light': False
}
# dungeon forms will be decided later
self.dungeon_mq = {
'Deku Tree': False,
'Dodongos Cavern': False,
'Jabu Jabus Belly': False,
'Bottom of the Well': False,
'Ice Cavern': False,
'Gerudo Training Grounds': False,
'Forest Temple': False,
'Fire Temple': False,
'Water Temple': False,
'Spirit Temple': False,
'Shadow Temple': False,
'Ganons Castle': False
}
self.can_take_damage = True
def copy(self):
new_world = World(self.settings)
new_world.skipped_trials = copy.copy(self.skipped_trials)
new_world.dungeon_mq = copy.copy(self.dungeon_mq)
new_world.big_poe_count = copy.copy(self.big_poe_count)
new_world.starting_tod = self.starting_tod
new_world.starting_age = self.starting_age
new_world.can_take_damage = self.can_take_damage
new_world.shop_prices = copy.copy(self.shop_prices)
new_world.id = self.id
new_world.distribution = self.distribution
new_world.regions = [region.copy(new_world) for region in self.regions]
for region in new_world.regions:
for exit in region.exits:
exit.connect(new_world.get_region(exit.connected_region))
new_world.dungeons = [dungeon.copy(new_world) for dungeon in self.dungeons]
new_world.itempool = [item.copy(new_world) for item in self.itempool]
new_world.state = self.state.copy(new_world)
return new_world
def load_regions_from_json(self, file_path):
json_string = ""
with io.open(file_path, 'r') as file:
for line in file.readlines():
json_string += line.split('#')[0].replace('\n', ' ')
json_string = re.sub(' +', ' ', json_string)
try:
region_json = json.loads(json_string)
except json.JSONDecodeError as error:
raise Exception("JSON parse error around text:\n" + \
json_string[error.pos-35:error.pos+35] + "\n" + \
" ^^\n")
for region in region_json:
new_region = Region(region['region_name'])
new_region.world = self
if 'hint' in region:
new_region.hint = region['hint']
if 'dungeon' in region:
new_region.dungeon = region['dungeon']
if 'time_passes' in region:
new_region.time_passes = region['time_passes']
if 'locations' in region:
for location, rule in region['locations'].items():
new_location = LocationFactory(location)
new_location.parent_region = new_region
if self.logic_rules != 'none':
new_location.access_rule = parse_rule_string(rule, self)
new_location.world = self
new_region.locations.append(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
if self.logic_rules != 'none':
new_exit.access_rule = parse_rule_string(rule, self)
new_region.exits.append(new_exit)
self.regions.append(new_region)
def initialize_entrances(self):
for region in self.regions:
for exit in region.exits:
exit.connect(self.get_region(exit.connected_region))
exit.world = self
def initialize_regions(self):
for region in self.regions:
region.world = self
for location in region.locations:
location.world = self
def initialize_items(self):
for item in self.itempool:
item.world = self
for region in self.regions:
for location in region.locations:
if location.item != None:
location.item.world = self
for item in [item for dungeon in self.dungeons for item in dungeon.all_items]:
item.world = self
def random_shop_prices(self):
shop_item_indexes = ['7', '5', '8', '6']
self.shop_prices = {}
for region in self.regions:
if self.shopsanity == 'random':
shop_item_count = random.randint(0,4)
else:
shop_item_count = int(self.shopsanity)
for location in region.locations:
if location.type == 'Shop':
if location.name[-1:] in shop_item_indexes[:shop_item_count]:
self.shop_prices[location.name] = int(random.betavariate(1.5, 2) * 60) * 5
def set_scrub_prices(self):
# Get Deku Scrub Locations
scrub_locations = [location for location in self.get_locations() if 'Deku Scrub' in location.name]
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.shuffle_scrubs == 'low':
price = 10
elif self.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 get_region(self, regionname):
if isinstance(regionname, Region):
return regionname
try:
return self._region_cache[regionname]
except KeyError:
for region in self.regions:
if region.name == regionname:
self._region_cache[regionname] = region
return region
raise KeyError('No such region %s' % regionname)
def get_entrance(self, entrance):
if isinstance(entrance, Entrance):
return entrance
try:
return self._entrance_cache[entrance]
except KeyError:
for region in self.regions:
for exit in region.exits:
if exit.name == entrance:
self._entrance_cache[entrance] = exit
return exit
raise KeyError('No such entrance %s' % entrance)
def get_location(self, location):
if isinstance(location, Location):
return location
try:
return self._location_cache[location]
except KeyError:
for region in self.regions:
for r_location in region.locations:
if r_location.name == location:
self._location_cache[location] = r_location
return r_location
raise KeyError('No such location %s' % location)
def get_items(self):
return [loc.item for loc in self.get_filled_locations()] + self.itempool
def get_itempool_with_dungeon_items(self):
return self.get_restricted_dungeon_items() + self.get_unrestricted_dungeon_items() + self.itempool
# get a list of items that should stay in their proper dungeon
def get_restricted_dungeon_items(self):
itempool = []
if self.shuffle_mapcompass == 'dungeon':
itempool.extend([item for dungeon in self.dungeons for item in dungeon.dungeon_items])
if self.shuffle_smallkeys == 'dungeon':
itempool.extend([item for dungeon in self.dungeons for item in dungeon.small_keys])
if self.shuffle_bosskeys == 'dungeon':
itempool.extend([item for dungeon in self.dungeons for item in dungeon.boss_key])
for item in itempool:
item.world = self
return itempool
# get a list of items that don't have to be in their proper dungeon
def get_unrestricted_dungeon_items(self):
itempool = []
if self.shuffle_mapcompass == 'keysanity':
itempool.extend([item for dungeon in self.dungeons for item in dungeon.dungeon_items])
if self.shuffle_smallkeys == 'keysanity':
itempool.extend([item for dungeon in self.dungeons for item in dungeon.small_keys])
if self.shuffle_bosskeys == 'keysanity':
itempool.extend([item for dungeon in self.dungeons for item in dungeon.boss_key])
for item in itempool:
item.world = self
return itempool
def find_items(self, item):
return [location for location in self.get_locations() if location.item is not None and location.item.name == item]
def push_item(self, location, item, manual=False):
if not isinstance(location, Location):
location = self.get_location(location)
# This check should never be false normally, but is here as a sanity check
if location.can_fill_fast(item, manual):
location.item = item
item.location = location
item.price = location.price if location.price is not None else item.price
location.price = item.price
logging.getLogger('').debug('Placed %s [World %d] at %s [World %d]', item, item.world.id if hasattr(item, 'world') else -1, location, location.world.id if hasattr(location, 'world') else -1)
else:
raise RuntimeError('Cannot assign item %s to location %s.' % (item, location))
def get_locations(self):
if self._cached_locations is None:
self._cached_locations = []
for region in self.regions:
self._cached_locations.extend(region.locations)
return self._cached_locations
def get_unfilled_locations(self):
return [location for location in self.get_locations() if location.item is None]
def get_filled_locations(self):
return [location for location in self.get_locations() if location.item is not None]
def get_reachable_locations(self, state=None):
if state is None:
state = self.state
return [location for location in self.get_locations() if state.can_reach(location)]
def get_placeable_locations(self, state=None):
if state is None:
state = self.state
return [location for location in self.get_locations() if location.item is None and state.can_reach(location)]
def unlocks_new_location(self, item):
temp_state = self.state.copy()
temp_state.clear_cached_unreachable()
temp_state.collect(item)
for location in self.get_unfilled_locations():
if temp_state.can_reach(location) and not self.state.can_reach(location):
return True
return False
def get_entrances(self):
return [entrance for region in self.regions for entrance in region.entrances]
def get_shuffled_entrances(self, type=None):
return [entrance for entrance in self.get_entrances() if entrance.shuffled and (type == None or entrance.type == type)]
def has_beaten_game(self, state):
return state.has('Triforce')
# Useless areas are areas that have contain no items that could ever
# be used to complete the seed. Unfortunately this is very difficult
# to calculate since that involves trying every possible path and item
# set collected to know this. To simplify this we instead just get areas
# that don't have any items that could ever be required in any seed.
# We further cull this list with woth info. This is an overestimate of
# the true list of possible useless areas, but this will generate a
# reasonably sized list of areas that fit this property.
def update_useless_areas(self, spoiler):
areas = {}
# Link's Pocket and None are not real areas
excluded_areas = [None, "Link's Pocket"]
for location in self.get_locations():
location_hint = get_hint_area(location)
# We exclude event and locked locations. This means that medallions
# and stones are not considered here. This is not really an accurate
# way of doing this, but it's the only way to allow dungeons to appear.
# So barren hints do not include these dungeon rewards.
if location_hint in excluded_areas or \
location.locked or \
location.item is None or \
location.item.type == "Event":
continue
area = location_hint
# Build the area list and their items
if area not in areas:
areas[area] = {
'locations': [],
}
areas[area]['locations'].append(location)
# Generate area list meta data
for area,area_info in areas.items():
# whether an area is a dungeon is calculated to prevent too many
# dungeon barren hints since they are quite powerful. The area
# names don't quite match the internal dungeon names so we need to
# check if any location in the area has a dungeon.
area_info['dungeon'] = False
for location in area_info['locations']:
if location.parent_region.dungeon is not None:
area_info['dungeon'] = True
break
# Weight the area's chance of being chosen based on its size.
# Small areas are more likely to barren, so we apply this weight
# to make all areas have a more uniform chance of being chosen
area_info['weight'] = len(area_info['locations'])
# these are items that can never be required but are still considered major items
exclude_item_list = [
'Double Defense',
'Ice Arrows',
'Serenade of Water',
'Prelude of Light',
'Biggoron Sword',
]
if self.damage_multiplier != 'ohko' and self.damage_multiplier != 'quadruple' and self.shuffle_scrubs == 'off':
# nayru's love may be required to prevent forced damage
exclude_item_list.append('Nayrus Love')
if self.hints != 'agony':
# Stone of Agony only required if it's used for hints
exclude_item_list.append('Stone of Agony')
# The idea here is that if an item shows up in woth, then the only way
# that another copy of that major item could ever be required is if it
# is a progressive item. Normally this applies to things like bows, bombs
# bombchus, bottles, slingshot, magic and ocarina. However if plentiful
# item pool is enabled this could be applied to any item.
duplicate_item_woth = {}
woth_loc = [location for world_woth in spoiler.required_locations.values() for location in world_woth]
for world in spoiler.worlds:
duplicate_item_woth[world.id] = {}
for location in woth_loc:
world_id = location.item.world.id
item = location.item
if item.name == 'Bottle with Letter' and item.name in duplicate_item_woth[world_id]:
# Only the first Letter counts as a letter, subsequent ones are Bottles.
# It doesn't matter which one is considered bottle/letter, since they will
# both we considered not useless.
item_name = 'Bottle'
elif item.special.get('bottle', False):
# Bottles can have many names but they are all generally the same in logic.
# The letter and big poe bottles will give a bottle item, so no additional
# checks are required for them.
item_name = 'Bottle'
else:
item_name = item.name
if item_name not in duplicate_item_woth[world_id]:
duplicate_item_woth[world_id][item_name] = []
duplicate_item_woth[world_id][item_name].append(location)
# generate the empty area list
self.empty_areas = {}
for area,area_info in areas.items():
useless_area = True
for location in area_info['locations']:
world_id = location.item.world.id
item = location.item
if (not location.item.majoritem) or (location.item.name in exclude_item_list):
# Minor items are always useless in logic
continue
is_bottle = False
if item.name == 'Bottle with Letter' and item.name in duplicate_item_woth[world_id]:
# If this is the required Letter then it is not useless
dupe_locations = duplicate_item_woth[world_id][item.name]
for dupe_location in dupe_locations:
if dupe_location.world.id == location.world.id and dupe_location.name == location.name:
useless_area = False
break
# Otherwise it is treated as a bottle
is_bottle = True
if is_bottle or item.special.get('bottle', False):
# Bottle Items are all interchangable. Logic currently only needs
# a max on 1 bottle, but this might need to be changed in the
# future if using multiple bottles for fire temple diving is added
# to logic
dupe_locations = duplicate_item_woth[world_id].get('Bottle', [])
max_progressive = 1
elif item.name == 'Bottle with Big Poe':
# The max number of requred Big Poe Bottles is based on the setting
dupe_locations = duplicate_item_woth[world_id].get(item.name, [])
max_progressive = self.settings.big_poe_count
else:
dupe_locations = duplicate_item_woth[world_id].get(item.name, [])
max_progressive = item.special.get('progressive', 1)
# If this is a required item location, then it is not useless
for dupe_location in dupe_locations:
if dupe_location.world.id == location.world.id and dupe_location.name == location.name:
useless_area = False
break
# If there are sufficient required item known, then the remaining
# copies of the items are useless.
if max_progressive is True or len(dupe_locations) < max_progressive:
useless_area = False
break
if useless_area:
self.empty_areas[area] = area_info