forked from AmazingAmpharos/OoT-Randomizer
-
Notifications
You must be signed in to change notification settings - Fork 243
Expand file tree
/
Copy pathFill.py
More file actions
711 lines (596 loc) · 33.3 KB
/
Fill.py
File metadata and controls
711 lines (596 loc) · 33.3 KB
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
from __future__ import annotations
import random
import logging
from typing import TYPE_CHECKING, Optional
from Hints import HintArea
from Item import Item, ItemFactory, ItemInfo
from ItemPool import item_groups, remove_junk_barren_items, removable_major_barren_items, remove_junk_items
from Location import Location, DisableType
from LocationList import location_groups
from Rules import set_shop_rules
from Search import Search
from State import State
if TYPE_CHECKING:
from World import World
logger = logging.getLogger('')
class ShuffleError(RuntimeError):
pass
class FillError(ShuffleError):
pass
def is_item_replaceable_barren(item: Item, settings) -> bool:
"""
Determines if an item can be replaced by Nothing in barren mode.
Returns False for items that must ALWAYS remain in the pool.
Args:
item: The item to check
settings: The world settings object
Returns:
bool: True if the item can be replaced, False otherwise
"""
if item.type == 'Shop':
return False
# Songs: depends on shuffle_song_items setting
if item.type == 'Song' and settings.shuffle_song_items != 'any':
return False
# Keys/BK: keep them in the pool if it's vanilla or own dungeon to preserve key logic
if item.type == 'SmallKey' and (settings.shuffle_smallkeys == 'vanilla' or settings.shuffle_smallkeys == 'dungeon'):
return False
if item.type == 'BossKey' and (settings.shuffle_bosskeys == 'vanilla' or settings.shuffle_bosskeys == 'dungeon'):
return False
# Ice Traps: NEVER replace (important gameplay role)
if item.name == 'Ice Trap':
return False
# Winner piece of heart isn't removed for junk ice trap
if item.name == 'Piece of Heart (Treasure Chest Game)' and settings.ice_trap_appearance == 'junk_only' and settings.junk_ice_traps != 'off':
return False
return True
def reduce_placed_items_to_barren(worlds: list[World]) -> None:
"""
Replaces placed items with "Nothing" if they are not required to beat the game.
This function is called AFTER items have been placed. It tests each major item
to see if the game is still beatable without it. If yes, the item is replaced
with "Nothing".
Args:
worlds: List of World objects with items already placed
"""
logger.info('Reducing placed items to barren minimum...')
logger.info('Testing each placed item to see if it is required...')
replaced_count = 0
tested_count = 0
# Collect all placed items (items in locations, not in the pool)
all_locations: list[Location] = [location for world in worlds for location in world.get_locations() if location.item is not None]
# Filter to testable items
testable_locations: list[Location] = []
for location in all_locations:
item = location.item
# Skip if item is already Nothing
if item.name == 'Nothing':
continue
# Skip if item type is protected
if not is_item_replaceable_barren(item, item.world.settings):
continue
testable_locations.append(location)
logger.info(f'Found {len(testable_locations)} testable items in placed locations')
# Randomize test order for variability
random.shuffle(testable_locations)
# Test each item
for location in testable_locations:
tested_count += 1
original_item = location.item
is_replaced = False
# Case 1: Replace junk directly (without testing)
if original_item.name in item_groups['Junk']:
is_replaced = replace_junk_item_with_nothing(location, original_item, worlds)
# Case 2: Replace junk songs (Prelude and Serenade) (without testing)
elif original_item.name in item_groups['JunkSong']:
is_replaced = replace_junk_item_with_nothing(location, original_item, worlds)
# Case 3: Replace junk dungeon items (maps/compasses) (without testing)
elif original_item.name in item_groups['Map'] or original_item.name in item_groups['Compass']:
is_replaced = replace_junk_item_with_nothing(location, original_item, worlds)
# Case 4: Replace all health upgrades when the goal is not heart count (without testing)
elif original_item.name in item_groups['HealthUpgrade'] and item.world.settings.shuffle_ganon_bosskey != 'hearts' and item.world.settings.bridge != 'hearts':
is_replaced = replace_junk_item_with_nothing(location, original_item, worlds)
# Case 5: Bottles can be removed in all locations reachable
elif original_item.name in item_groups['Bottle']:
is_replaced = replace_major_item_with_nothing(location, original_item, worlds)
# Case 6: Major items can be removed because they unlock no location
elif original_item.name in remove_junk_barren_items:
is_replaced = replace_major_item_with_nothing(location, original_item, worlds)
# Case 7: Major items can be replaced by other items to unlock locations
elif original_item.name in removable_major_barren_items:
is_replaced = replace_major_item_with_nothing(location, original_item, worlds)
# Default: Replace major items only when reachable_locations is 'beatable'
elif item.world.settings.reachable_locations == 'beatable':
is_replaced = replace_major_item_with_nothing(location, original_item, worlds)
if is_replaced:
replaced_count += 1
logger.info(f'Barren reduction complete: {replaced_count}/{tested_count} items replaced with Nothing')
# Rebuild item_pool for each world based on the final placed items
# This ensures the spoiler log shows the correct item counts after barren reduction
for world in worlds:
placed_items = []
for location in world.get_locations():
if location.item is None:
continue
item = location.item
placed_items.append(item)
# Rebuild the item_pool with the placed items
# This will filter out dungeon items, drops, events, and rewards automatically
world.distribution.set_complete_itempool(placed_items)
logger.info(f'Rebuilt item_pool for world {world.id}: {len(world.distribution.item_pool)} unique items')
def replace_major_item_with_nothing(location: Location, original_item: Item | None, worlds: list[World]):
# Temporarily replace with Nothing
nothing_item = ItemFactory('Nothing', original_item.world)
nothing_item.location = location
location.item = nothing_item
# Test if the game is still beatable
try:
test_search = Search([w.state for w in worlds])
beatable = test_search.can_beat_game(scan_for_items=True)
except Exception as e:
logger.warning(f'Error testing {original_item.name} at {location.name}: {e}')
beatable = False
if beatable:
# Keep the Nothing - item is not required
logger.debug(f'Replaced {original_item.name} at {location.name} with Nothing')
return True
else:
# Restore original item - it's required
logger.debug(f'Cannot replace {original_item.name} at {location.name} with Nothing')
location.item = original_item
return False
def replace_junk_item_with_nothing(location: Location, original_item: Item | None, worlds: list[World]):
# Directly replace with Nothing
nothing_item = ItemFactory('Nothing', original_item.world)
nothing_item.location = location
location.item = nothing_item
return True
# Places all items into the world
def distribute_items_restrictive(worlds: list[World], fill_locations: Optional[list[Location]] = None) -> None:
if worlds[0].settings.shuffle_song_items == 'song':
song_location_names = location_groups['Song']
elif worlds[0].settings.shuffle_song_items == 'dungeon':
song_location_names = [
'Deku Tree Queen Gohma Heart', 'Dodongos Cavern King Dodongo Heart',
'Jabu Jabus Belly Barinade Heart', 'Forest Temple Phantom Ganon Heart',
'Fire Temple Volvagia Heart', 'Water Temple Morpha Heart',
'Spirit Temple Twinrova Heart', 'Shadow Temple Bongo Bongo Heart',
'Sheik in Ice Cavern', 'Song from Impa',
'Gerudo Training Ground MQ Ice Arrows Chest',
'Gerudo Training Ground Maze Path Final Chest',
'Bottom of the Well Lens of Truth Chest',
'Bottom of the Well MQ Lens of Truth Chest']
else:
song_location_names = []
song_locations = []
for world in worlds:
for location_name in song_location_names:
try:
song_locations.append(world.get_location(location_name))
except KeyError:
pass
shop_locations = [location for world in worlds for location in world.get_unfilled_locations() if location.type == 'Shop' and location.price is None]
# If not passed in, then get a shuffled list of locations to fill in
if not fill_locations:
fill_locations = [
location for world in worlds for location in world.get_unfilled_locations()
if location not in song_locations
and location not in shop_locations
and not location.type.startswith('Hint')]
world_states = [world.state for world in worlds]
# Generate the itempools
shopitempool = [item for world in worlds for item in world.itempool if item.type == 'Shop']
songitempool = [item for world in worlds for item in world.itempool if item.type == 'Song']
itempool = [item for world in worlds for item in world.itempool if item.type != 'Shop' and item.type != 'Song']
if worlds[0].settings.shuffle_song_items == 'any':
itempool.extend(songitempool)
songitempool = []
# Unrestricted dungeon items are already in main item pool
dungeon_items = [item for world in worlds for item in world.get_restricted_dungeon_items()]
random.shuffle(itempool) # randomize item placement order. this ordering can greatly affect the location accessibility bias
progitempool = [item for item in itempool if item.advancement]
prioitempool = [item for item in itempool if not item.advancement and item.priority]
restitempool = [item for item in itempool if not item.advancement and not item.priority]
cloakable_locations = shop_locations + song_locations + fill_locations
all_models = shopitempool + dungeon_items + songitempool + itempool
worlds[0].settings.distribution.fill(worlds, [shop_locations, song_locations, fill_locations], [shopitempool, dungeon_items, songitempool, progitempool, prioitempool, restitempool])
itempool = progitempool + prioitempool + restitempool
# set ice traps to have the appearance of other random items in the item pool
ice_traps = [item for item in itempool if item.name == 'Ice Trap']
# Extend with ice traps manually placed in plandomizer
ice_traps.extend(
location.item
for location in cloakable_locations
if location.item is not None
and location.item.name == 'Ice Trap'
and location.item.looks_like_item is None
)
junk_items = remove_junk_items.copy()
junk_items.remove('Ice Trap')
major_items = [name for name, item in ItemInfo.items.items() if item.type == 'Item' and item.advancement and item.index is not None]
fake_items = []
if worlds[0].settings.ice_trap_appearance == 'major_only':
model_items = [item for item in itempool if item.majoritem]
if len(model_items) == 0: # All major items were somehow removed from the pool (can happen in plando)
model_items = ItemFactory(major_items)
elif worlds[0].settings.ice_trap_appearance == 'junk_only':
model_items = [item for item in itempool if item.name in junk_items]
if len(model_items) == 0: # All junk was removed
model_items = ItemFactory(junk_items)
else: # world[0].settings.ice_trap_appearance == 'anything':
model_items = [item for item in itempool if item.name != 'Ice Trap']
if len(model_items) == 0: # All major items and junk were somehow removed from the pool (can happen in plando)
model_items = ItemFactory(major_items) + ItemFactory(junk_items)
while len(ice_traps) > len(fake_items):
# if there are more ice traps than model items, then double up on model items
fake_items.extend(model_items)
for random_item in random.sample(fake_items, len(ice_traps)):
ice_trap = ice_traps.pop(0)
ice_trap.looks_like_item = random_item
# Start a search cache here.
search = Search([world.state for world in worlds])
# We place all the shop items first. Like songs, they have a more limited
# set of locations that they can be placed in, so placing them first will
# reduce the odds of creating unbeatable seeds. This also avoids needing
# to create item rules for every location for whether they are a shop item
# or not. This shouldn't have much effect on item bias.
if shop_locations:
logger.info('Placing shop items.')
fill_ownworld_restrictive(worlds, search, shop_locations, shopitempool, itempool + songitempool + dungeon_items, "shop")
# Update the shop item access rules
for world in worlds:
set_shop_rules(world)
search.collect_locations()
# If there are dungeon items that are restricted to their original dungeon,
# we must place them first to make sure that there is always a location to
# place them. This could probably be replaced for more intelligent item
# placement, but will leave as is for now
if dungeon_items:
logger.info('Placing dungeon items.')
fill_dungeons_restrictive(worlds, search, fill_locations, dungeon_items, itempool + songitempool)
search.collect_locations()
# If some dungeons are supposed to be empty, fill them with useless items.
if worlds[0].settings.empty_dungeons_mode != 'none':
empty_locations = [
location
for location in fill_locations
if location.world.precompleted_dungeons.get(HintArea.at(location).dungeon_name, False)
]
for location in empty_locations:
fill_locations.remove(location)
if worlds[0].settings.shuffle_mapcompass in ['any_dungeon', 'overworld', 'keysanity', 'regional']:
# Non-empty dungeon items are present in restitempool but yet we
# don't want to place them in an empty dungeon
restdungeon, restother = [], []
for item in restitempool:
if item.dungeonitem:
restdungeon.append(item)
else:
restother.append(item)
fast_fill(empty_locations, restother)
restitempool = restdungeon + restother
random.shuffle(restitempool)
else:
# We don't have to worry about this if dungeon items stay in their own dungeons
fast_fill(empty_locations, restitempool)
# places the songs into the world
# Currently places songs only at song locations. if there's an option
# to allow at other locations then they should be in the main pool.
# Placing songs on their own since they have a relatively high chance
# of failing compared to other item type. So this way we only have retry
# the song locations only.
if worlds[0].settings.shuffle_song_items != 'any':
logger.info('Placing song items.')
fill_ownworld_restrictive(worlds, search, song_locations, songitempool, progitempool, "song")
search.collect_locations()
fill_locations += [location for location in song_locations if location.item is None]
# Put one item in every dungeon, needs to be done before other items are
# placed to ensure there is a spot available for them
if worlds[0].settings.one_item_per_dungeon:
logger.info('Placing one major item per dungeon.')
fill_dungeon_unique_item(worlds, search, fill_locations, progitempool)
search.collect_locations()
# Place all progression items. This will include keys in keysanity.
# Items in this group will check for reachability and will be placed
# such that the game is guaranteed beatable.
logger.info('Placing progression items.')
fill_restrictive(worlds, search, fill_locations, progitempool)
search.collect_locations()
# Place all priority items.
# These items are items that only check if the item is allowed to be
# placed in the location, not checking reachability. This is important
# for things like Ice Traps that can't be found at some locations
logger.info('Placing priority items.')
fill_restrictive_fast(worlds, fill_locations, prioitempool)
# Place the rest of the items.
# No restrictions at all. Places them completely randomly. Since they
# cannot affect the beatability, we don't need to check them
logger.info('Placing the rest of the items.')
fast_fill(fill_locations, restitempool)
# Log unplaced item/location warnings
for item in progitempool + prioitempool + restitempool:
logger.error('Unplaced Items: %s [World %d]' % (item.name, item.world.id))
for location in fill_locations:
logger.error('Unfilled Locations: %s [World %d]' % (location.name, location.world.id))
if progitempool + prioitempool + restitempool:
raise FillError('Not all items are placed.')
if fill_locations:
raise FillError('Not all locations have an item.')
if not search.can_beat_game():
raise FillError('Cannot beat game!')
worlds[0].settings.distribution.cloak(worlds, [cloakable_locations], [all_models])
for world in worlds:
for location in world.get_filled_locations():
# Get the maximum amount of wallets required to purchase an advancement item.
if world.maximum_wallets < 3 and location.price and location.item.advancement:
if location.price > 500:
world.maximum_wallets = 3
elif world.maximum_wallets < 2 and location.price > 200:
world.maximum_wallets = 2
elif world.maximum_wallets < 1 and location.price > 99:
world.maximum_wallets = 1
# Places restricted dungeon items into the worlds. To ensure there is room for them.
# they are placed first, so it will assume all other items are reachable
def fill_dungeons_restrictive(worlds: list[World], search: Search, shuffled_locations: list[Location], dungeon_items: list[Item], itempool: list[Item]) -> None:
# List of states with all non-key items
base_search = search.copy()
base_search.collect_all(itempool)
base_search.collect_locations()
# shuffle this list to avoid placement bias
random.shuffle(dungeon_items)
# sort in the order Other, Small Key, Boss Key before placing dungeon items
# python sort is stable, so the ordering is still random within groups
# fill_restrictive processes the resulting list backwards so the Boss Keys will actually be placed first
sort_order = {"BossKey": 3, "GanonBossKey": 3, "SmallKey": 2, "SmallKeyRing": 2}
dungeon_items.sort(key=lambda item: sort_order.get(item.type, 1))
# place dungeon items
fill_restrictive(worlds, base_search, shuffled_locations, dungeon_items)
# Places items into dungeon locations. This is used when there should be exactly
# one progression item per dungeon. This should be run before all the progression
# items are places to ensure there is space to place them.
def fill_dungeon_unique_item(worlds: list[World], search: Search, fill_locations: list[Location], itempool: list[Item]) -> None:
# We should make sure that we don't count event items, shop items,
# token items, or dungeon items as a major item. itempool at this
# point should only be able to have tokens of those restrictions
# since the rest are already placed.
major_items = [item for item in itempool if item.majoritem]
minor_items = [item for item in itempool if not item.majoritem]
if worlds[0].settings.empty_dungeons_mode != 'none':
dungeons = [dungeon for world in worlds for dungeon in world.dungeons if not world.precompleted_dungeons.get(dungeon.name, False)]
else:
dungeons = [dungeon for world in worlds for dungeon in world.dungeons]
double_dungeons = []
for dungeon in dungeons:
# we will count spirit temple twice so that it gets 2 items to match vanilla
if dungeon.name == 'Spirit Temple':
double_dungeons.append(dungeon)
dungeons.extend(double_dungeons)
random.shuffle(dungeons)
random.shuffle(itempool)
base_search = search.copy()
base_search.collect_all(minor_items)
base_search.collect_locations()
all_dungeon_locations = []
# iterate of all the dungeons in a random order, placing the item there
for dungeon in dungeons:
# Need to re-get dungeon regions to ensure boss rooms are considered
regions = []
for region in dungeon.world.regions:
try:
if HintArea.at(region).dungeon_name == dungeon.name:
regions.append(region)
except:
pass
dungeon_locations = [location for region in regions for location in region.locations if location in fill_locations]
# cache this list to flag afterwards
all_dungeon_locations.extend(dungeon_locations)
# Sort major items in such a way that they are placed first if dungeon restricted.
# There still won't be enough locations for small keys in one item per dungeon mode, though.
for item in list(major_items):
if not item.world.get_region('Root').can_fill(item):
major_items.remove(item)
major_items.append(item)
# place 1 item into the dungeon
try:
fill_restrictive(worlds, base_search, dungeon_locations, major_items, 1)
except FillError as e:
raise FillError(f'Could not place a major item in {dungeon} because there are no remaining locations in the dungeon. If you have excluded some of the locations in this dungeon, try reincluding one.') from e
# update the location and item pool, removing any placed items and filled locations
# the fact that you can remove items from a list you're iterating over is python magic
for item in itempool:
if item.location is not None:
fill_locations.remove(item.location)
itempool.remove(item)
# flag locations to not place further major items. it's important we do it on the
# locations instead of the dungeon because some locations are not in the dungeon
for location in all_dungeon_locations:
location.minor_only = True
# Error out if we have any items that won't be placeable in the overworld left.
for item in major_items:
if not item.world.get_region('Root').can_fill(item):
raise FillError(f"No more dungeon locations available for {item.name} to be placed with 'Dungeons Have One Major Item' enabled. To fix this, either disable 'Dungeons Have One Major Item' or enable some settings that add more locations for shuffled items in the overworld.")
logger.info("Unique dungeon items placed")
# Places items restricting placement to the recipient player's own world
def fill_ownworld_restrictive(worlds: list[World], search: Search, locations: list[Location], ownpool: list[Item],
itempool: list[Item], description: str = "Unknown", attempts: int = 15) -> None:
# look for preplaced items
placed_prizes = [loc.item.name for loc in locations if loc.item is not None]
unplaced_prizes = [item for item in ownpool if item.name not in placed_prizes]
empty_locations = [loc for loc in locations if loc.item is None]
prizepool_dict = {world.id: [item for item in unplaced_prizes if item.world.id == world.id] for world in worlds}
prize_locs_dict = {world.id: [loc for loc in empty_locations if loc.world.id == world.id] for world in worlds}
# Shop item being sent in to this method are tied to their own world.
# Therefore, let's do this one world at a time. We do this to help
# increase the chances of successfully placing songs
for world in worlds:
# List of states with all items
unplaced_prizes = [item for item in unplaced_prizes if item not in prizepool_dict[world.id]]
base_search = search.copy()
base_search.collect_all(itempool + unplaced_prizes)
world_attempts = attempts
while world_attempts:
world_attempts -= 1
try:
prizepool = list(prizepool_dict[world.id])
prize_locs = list(prize_locs_dict[world.id])
random.shuffle(prizepool)
fill_restrictive(worlds, base_search, prize_locs, prizepool)
logger.info("Placed %s items for world %s.", description, (world.id+1))
except FillError as e:
logger.info("Failed to place %s items for world %s. Will retry %s more times.", description, (world.id+1), world_attempts)
for location in prize_locs_dict[world.id]:
location.item = None
location.price = None
if location.disabled == DisableType.DISABLED:
location.disabled = DisableType.PENDING
logger.info('\t%s' % str(e))
continue
break
else:
raise FillError(f'Unable to place {description} items in world {world.id + 1}')
# Places items in the itempool into locations.
# worlds is a list of worlds and is redundant of the worlds in the base_state_list
# base_state_list is a list of world states prior to placing items in the item pool
# items and locations have pointers to the world that they belong to
#
# The algorithm places items in the world in reverse.
# This means we first assume we have every item in the item pool and
# remove an item and try to place it somewhere that is still reachable
# This method helps distribution of items locked behind many requirements
#
# count is the number of items to place. If count is negative, then it will place
# every item. Raises an error if specified count of items are not placed.
#
# This function will modify the location and itempool arguments. placed items and
# filled locations will be removed. If this returns an error, then the state of
# those two lists cannot be guaranteed.
def fill_restrictive(worlds: list[World], base_search: Search, locations: list[Location], itempool: list[Item], count: int = -1) -> None:
unplaced_items = []
# don't run over this search, just keep it as an item collection
items_search = base_search.copy()
items_search.collect_all(itempool)
logging.getLogger('').debug(f'Placing {len(itempool)} items among {len(locations)} potential locations.')
itempool.sort(key=lambda item: not item.priority)
# loop until there are no items or locations
while itempool and locations:
# if remaining count is 0, return. Negative means unbounded.
if count == 0:
break
# get an item and remove it from the itempool
item_to_place = itempool.pop()
if item_to_place.priority:
l2cations = [l for l in locations if l.can_fill_fast(item_to_place)]
elif item_to_place.majoritem:
l2cations = [l for l in locations if not l.minor_only]
else:
l2cations = locations
random.shuffle(l2cations)
# generate the max search with every remaining item
# this will allow us to place this item in a reachable location
items_search.uncollect(item_to_place)
max_search = items_search.copy()
max_search.collect_locations()
# perform_access_check checks location reachability
if worlds[0].check_beatable_only:
if worlds[0].settings.reachable_locations == 'goals':
# If this item is required for a goal, it must be placed somewhere reachable.
# We also need to check to make sure the game is beatable, since custom goals might not imply that.
predicate = lambda state: state.won() and state.has_all_item_goals()
else:
# If the game is not beatable without this item, it must be placed somewhere reachable.
predicate = State.won
perform_access_check = not max_search.can_beat_game(scan_for_items=False, predicate=predicate)
else:
# All items must be placed somewhere reachable.
perform_access_check = True
# find a location that the item can be placed. It must be a valid location
# in the world we are placing it (possibly checking for reachability)
spot_to_fill = None
for location in l2cations:
if location.can_fill(max_search.state_list[location.world.id], item_to_place, perform_access_check):
# for multiworld, make it so that the location is also reachable
# in the world the item is for. This is to prevent early restrictions
# in one world being placed late in another world. If this is not
# done then one player may be waiting a long time for other players.
if location.world.id != item_to_place.world.id:
try:
source_location = item_to_place.world.get_location(location.name)
if not source_location.can_fill(max_search.state_list[item_to_place.world.id], item_to_place, perform_access_check):
# location wasn't reachable in item's world, so skip it
continue
except KeyError:
# This location doesn't exist in the other world, let's look elsewhere.
# Check access to whatever parent region exists in the other world.
can_reach = True
parent_region = location.parent_region
while parent_region:
try:
source_region = item_to_place.world.get_region(parent_region.name)
can_reach = max_search.can_reach(source_region)
break
except KeyError:
parent_region = parent_region.entrances[0].parent_region
if not can_reach:
continue
if location.disabled == DisableType.PENDING:
if not max_search.can_beat_game(False):
continue
location.disabled = DisableType.DISABLED
# location is reachable (and reachable in item's world), so place item here
spot_to_fill = location
break
# if we failed to find a suitable location
if spot_to_fill is None:
# if we specify a count, then we only want to place a subset, so a miss might be ok
if count > 0:
# don't decrement count, we didn't place anything
unplaced_items.append(item_to_place)
items_search.collect(item_to_place)
continue
else:
# we expect all items to be placed
raise FillError(f'Game unbeatable: No more spots to place {item_to_place} [World {item_to_place.world.id + 1}] from {len(l2cations)} locations ({len(locations)} total); {len(itempool)} other items left to place, plus {len(unplaced_items)} skipped')
# Place the item in the world and continue
spot_to_fill.world.push_item(spot_to_fill, item_to_place)
locations.remove(spot_to_fill)
# decrement count
count -= 1
# assert that the specified number of items were placed
if count > 0:
raise FillError(f'Could not place the specified number of item. {count} remaining to be placed.')
if count < 0 < len(itempool):
raise FillError(f'Could not place all the items. {len(itempool)} remaining to be placed.')
# re-add unplaced items that were skipped
itempool.extend(unplaced_items)
# This places items in the itempool into the locations
# It does not check for reachability, only that the item is
# allowed in the location
def fill_restrictive_fast(worlds: list[World], locations: list[Location], itempool: list[Item]) -> None:
while itempool and locations:
item_to_place = itempool.pop()
random.shuffle(locations)
# get location that allows this item
spot_to_fill = None
for location in locations:
if location.can_fill_fast(item_to_place):
spot_to_fill = location
break
# if we failed to find a suitable location, then stop placing items
# we don't need to check beatability since world must be beatable
# at this point
if spot_to_fill is None:
if not worlds[0].check_beatable_only:
logger.debug('Not all items placed. Game beatable anyway.')
break
# Place the item in the world and continue
spot_to_fill.world.push_item(spot_to_fill, item_to_place)
locations.remove(spot_to_fill)
# this places item in item_pool completely randomly into
# fill_locations. There is no checks for validity since
# there should be none for these remaining items
def fast_fill(locations: list[Location], itempool: list[Item]) -> None:
random.shuffle(locations)
while itempool and locations:
spot_to_fill = locations.pop()
item_to_place = itempool.pop()
spot_to_fill.world.push_item(spot_to_fill, item_to_place)