forked from ArchipelagoMW/Archipelago
- Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathFill.py
More file actions
Latest commit
1166 lines (1018 loc) · 60.1 KB
/
Copy pathFill.py
File metadata and controls
1166 lines (1018 loc) · 60.1 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
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
importcollections
importitertools
importlogging
importtyping
fromcollectionsimportCounter, deque
fromBaseClassesimportCollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
fromOptionsimportAccessibility
fromworlds.AutoWorldimportcall_all
fromworlds.generic.Rulesimportadd_item_rule
classFillError(RuntimeError):
def__init__(self, *args: typing.Union[str, typing.Any], **kwargs) ->None:
if"multiworld"inkwargsandisinstance(args[0], str):
placements= (args[0] +f"\nAll Placements:\n"+
f"{[(loc, loc.item) forlocinkwargs['multiworld'].get_filled_locations()]}")
args= (placements, *args[1:])
super().__init__(*args)
def_log_fill_progress(name: str, placed: int, total_items: int) ->None:
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
defsweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] =tuple(),
locations: typing.Optional[typing.List[Location]] =None) ->CollectionState:
new_state=base_state.copy()
foriteminitempool:
new_state.collect(item, True)
new_state.sweep_for_advancements(locations=locations)
returnnew_state
deffill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool=False, lock: bool=False,
swap: bool=True, on_place: typing.Optional[typing.Callable[[Location], None]] =None,
allow_partial: bool=False, allow_excluded: bool=False, one_item_per_player: bool=True,
name: str="Unknown") ->None:
"""
:param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool, gets mutated by removing locations that get filled.
:param item_pool: Items to fill into the locations, gets mutated by removing items that get placed.
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
:param name: name of this fill step for progress logging purposes
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required=False
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] =Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
foriteminitem_pool:
reachable_items.setdefault(item.player, deque()).append(item)
# for progress logging
total=min(len(item_pool), len(locations))
placed=0
whileany(reachable_items.values()) andlocations:
ifone_item_per_player:
# grab one item per player
items_to_place= [items.pop()
foritemsinreachable_items.values() ifitems]
else:
next_player=multiworld.random.choice([playerforplayer, itemsinreachable_items.items() ifitems])
items_to_place= []
ifitem_pool:
items_to_place.append(reachable_items[next_player].pop())
foriteminitems_to_place:
# The items added into `reachable_items` are placed starting from the end of each deque in
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
forp, pool_iteminenumerate(reversed(item_pool), start=1):
ifpool_itemisitem:
delitem_pool[-p]
break
maximum_exploration_state=sweep_from_pool(
base_state, item_pool+unplaced_items, multiworld.get_filled_locations(item.player)
ifsingle_player_placementelseNone)
has_beaten_game=multiworld.has_beaten_game(maximum_exploration_state)
whileitems_to_place:
# if we have run out of locations to fill,break out of this loop
ifnotlocations:
unplaced_items+=items_to_place
break
item_to_place=items_to_place.pop(0)
spot_to_fill: typing.Optional[Location] =None
# if minimal accessibility, only check whether location is reachable if game not beatable
ifmultiworld.worlds[item_to_place.player].options.accessibility==Accessibility.option_minimal:
perform_access_check=notmultiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
ifsingle_player_placementelsenothas_beaten_game
else:
perform_access_check=True
fori, locationinenumerate(locations):
if (notsingle_player_placementorlocation.player==item_to_place.player) \
andlocation.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
# popping by index is faster than removing by content,
spot_to_fill=locations.pop(i)
# skipping a scan for the element
break
else:
# we filled all reachable spots.
ifswap:
# Keep a cache of previous safe swap states that might be usable to sweep from to produce the next
# swap state, instead of sweeping from `base_state` each time.
previous_safe_swap_state_cache: typing.Deque[CollectionState] =deque()
# Almost never are more than 2 states needed. The rare cases that do are usually highly restrictive
# single_player_placement=True pre-fills which can go through more than 10 states in some seeds.
max_swap_base_state_cache_length=3
# try swapping this item with previously placed items in a safe way then in an unsafe way
swap_attempts= ((i, location, unsafe)
forunsafein (False, True)
fori, locationinenumerate(placements))
for (i, location, unsafe) inswap_attempts:
placed_item=location.item
ifitem_to_place==placed_item:
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
# itself.
continue
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
swap_count=swapped_items[placed_item.player, placed_item.name, unsafe]
ifswap_count>1:
continue
location.item=None
placed_item.location=None
forprevious_safe_swap_stateinprevious_safe_swap_state_cache:
# If a state has already checked the location of the swap, then it cannot be used.
iflocationnotinprevious_safe_swap_state.advancements:
# Previous swap states will have collected all items in `item_pool`, so the new
# `swap_state` can skip having to collect them again.
# Previous swap states will also have already checked many locations, making the sweep
# faster.
swap_state=sweep_from_pool(previous_safe_swap_state, (placed_item,) ifunsafeelse (),
multiworld.get_filled_locations(item.player)
ifsingle_player_placementelseNone)
break
else:
# No previous swap_state was usable as a base state to sweep from, so create a new one.
swap_state=sweep_from_pool(base_state, [placed_item, *item_pool] ifunsafeelseitem_pool,
multiworld.get_filled_locations(item.player)
ifsingle_player_placementelseNone)
# Unsafe states should not be added to the cache because they have collected `placed_item`.
ifnotunsafe:
iflen(previous_safe_swap_state_cache) >=max_swap_base_state_cache_length:
# Remove the oldest cached state.
previous_safe_swap_state_cache.pop()
# Add the new state to the start of the cache.
previous_safe_swap_state_cache.appendleft(swap_state)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
if (notsingle_player_placementorlocation.player==item_to_place.player) \
andlocation.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill=placements.pop(i)
swap_count+=1
swapped_items[placed_item.player, placed_item.name, unsafe] =swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required=True
break
# Item can't be placed here, restore original item
location.item=placed_item
placed_item.location=location
ifspot_to_fillisNone:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue
else:
unplaced_items.append(item_to_place)
continue
multiworld.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked=lock
placements.append(spot_to_fill)
placed+=1
ifnotplaced%1000:
_log_fill_progress(name, placed, total)
ifon_place:
on_place(spot_to_fill)
iftotal>1000:
_log_fill_progress(name, placed, total)
ifcleanup_required:
# validate all placements and remove invalid ones
state=sweep_from_pool(
base_state, [], multiworld.get_filled_locations(item.player)
ifsingle_player_placementelseNone)
forplacementinplacements:
ifmultiworld.worlds[placement.item.player].options.accessibility!="minimal"andnotplacement.can_reach(state):
placement.item.location=None
unplaced_items.append(placement.item)
placement.item=None
locations.append(placement)
ifallow_excluded:
# check if partial fill is the result of excluded locations, in which case retry
excluded_locations= [
locationforlocationinlocations
iflocation.progress_type==location.progress_type.EXCLUDEDandnotlocation.item
]
ifexcluded_locations:
forlocationinexcluded_locations:
location.progress_type=location.progress_type.DEFAULT
fill_restrictive(multiworld, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False)
forlocationinexcluded_locations:
ifnotlocation.item:
location.progress_type=location.progress_type.EXCLUDED
ifnotallow_partialandlen(unplaced_items) >0andlen(locations) >0:
# There are leftover unplaceable items and locations that won't accept them
ifmultiworld.can_beat_game():
logging.warning(
f"Not all items placed. Game beatable anyway.\nCould not place:\n"
f"{', '.join(str(item) foriteminunplaced_items)}")
else:
raiseFillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) foriteminunplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) forlocationinlocations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) forplaceinplacements)}", multiworld=multiworld)
item_pool.extend(unplaced_items)
defremaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item],
name: str="Remaining",
move_unplaceable_to_start_inventory: bool=False,
check_location_can_fill: bool=False) ->None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] =Counter()
total=min(len(itempool), len(locations))
placed=0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
ifcheck_location_can_fill:
state=CollectionState(multiworld)
deflocation_can_fill_item(location_to_fill: Location, item_to_fill: Item):
returnlocation_to_fill.can_fill(state, item_to_fill, check_access=False)
else:
deflocation_can_fill_item(location_to_fill: Location, item_to_fill: Item):
returnlocation_to_fill.item_rule(item_to_fill)
whilelocationsanditempool:
item_to_place=itempool.pop()
spot_to_fill: typing.Optional[Location] =None
# going through locations in the same order as the provided `locations` argument
fori, locationinenumerate(locations):
iflocation_can_fill_item(location, item_to_place):
# popping by index is faster than removing by content,
spot_to_fill=locations.pop(i)
# skipping a scan for the element
break
else:
# we filled all reachable spots.
# try swapping this item with previously placed items
for (i, location) inenumerate(placements):
placed_item=location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
ifswapped_items[placed_item.player,
placed_item.name] >1:
continue
location.item=None
placed_item.location=None
iflocation_can_fill_item(location, item_to_place):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill=placements.pop(i)
swapped_items[placed_item.player,
placed_item.name] +=1
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item=placed_item
placed_item.location=location
ifspot_to_fillisNone:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue
multiworld.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
placed+=1
ifnotplaced%1000:
_log_fill_progress(name, placed, total)
iftotal>1000:
_log_fill_progress(name, placed, total)
ifunplaced_itemsandlocations:
# There are leftover unplaceable items and locations that won't accept them
ifmove_unplaceable_to_start_inventory:
last_batch= []
foriteminunplaced_items:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
last_batch.append(multiworld.worlds[item.player].create_filler())
remaining_fill(multiworld, locations, unplaced_items, name+" Start Inventory Retry")
else:
raiseFillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) foriteminunplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) forlocationinlocations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) forplaceinplacements)}", multiworld=multiworld)
itempool.extend(unplaced_items)
deffast_fill(multiworld: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) ->typing.Tuple[typing.List[Item], typing.List[Location]]:
placing=min(len(item_pool), len(fill_locations))
foritem, locationinzip(item_pool, fill_locations):
multiworld.push_item(location, item, False)
returnitem_pool[placing:], fill_locations[placing:]
defaccessibility_corrections(multiworld: MultiWorld,
state: CollectionState,
locations: list[Location],
pool: list[Item] |None=None) ->None:
ifpoolisNone:
pool= []
maximum_exploration_state=sweep_from_pool(state, pool)
minimal_players= {playerforplayerinmultiworld.player_idsif
multiworld.worlds[player].options.accessibility=="minimal"}
unreachable_locations= [locationforlocationinmultiworld.get_locations() if
location.playerinminimal_playersand
notlocation.can_reach(maximum_exploration_state)]
forlocationinunreachable_locations:
if (location.itemisnotNoneandlocation.item.advancementandlocation.addressisnotNoneandnot
location.lockedandlocation.item.playernotinminimal_players):
pool.append(location.item)
location.item=None
iflocationinstate.advancements:
state.advancements.remove(location)
state.remove(location.item)
locations.append(location)
ifpoolandlocations:
locations.sort(key=lambdaloc: loc.progress_type!=LocationProgressType.PRIORITY)
fill_restrictive(multiworld, state, locations, pool, name="Accessibility Corrections")
definaccessible_location_rules(multiworld: MultiWorld, state: CollectionState, locations):
maximum_exploration_state=sweep_from_pool(state)
unreachable_locations= [locationforlocationinlocationsifnotlocation.can_reach(maximum_exploration_state)]
ifunreachable_locations:
defforbid_important_item_rule(item: Item):
returnnot ((item.classification&0b0011) andmultiworld.worlds[item.player].options.accessibility!="minimal")
forlocationinunreachable_locations:
add_item_rule(location, forbid_important_item_rule)
defdistribute_early_items(multiworld: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) ->typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
forplayerinmultiworld.player_ids:
items=itertools.chain(multiworld.early_items[player], multiworld.local_early_items[player])
foriteminitems:
early_items_count[item, player] = [multiworld.early_items[player].get(item, 0),
multiworld.local_early_items[player].get(item, 0)]
ifearly_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] =set()
base_state=multiworld.state.copy()
base_state.sweep_for_advancements(locations=(locforlocinmultiworld.get_filled_locations() ifloc.addressisNone))
fori, locinenumerate(fill_locations):
ifloc.can_reach(base_state):
ifloc.progress_type==LocationProgressType.PRIORITY:
early_priority_locations.append(loc)
else:
early_locations.append(loc)
loc_indexes_to_remove.add(i)
fill_locations= [locfori, locinenumerate(fill_locations) ifinotinloc_indexes_to_remove]
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] forplayerinmultiworld.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] forplayerinmultiworld.player_ids}
item_indexes_to_remove: typing.Set[int] =set()
fori, iteminenumerate(itempool):
if (item.name, item.player) inearly_items_count:
ifitem.advancement:
ifearly_items_count[item.name, item.player][1]:
early_local_prog_items[item.player].append(item)
early_items_count[item.name, item.player][1] -=1
else:
early_prog_items.append(item)
early_items_count[item.name, item.player][0] -=1
else:
ifearly_items_count[item.name, item.player][1]:
early_local_rest_items[item.player].append(item)
early_items_count[item.name, item.player][1] -=1
else:
early_rest_items.append(item)
early_items_count[item.name, item.player][0] -=1
item_indexes_to_remove.add(i)
ifearly_items_count[item.name, item.player] == [0, 0]:
delearly_items_count[item.name, item.player]
iflen(early_items_count) ==0:
break
itempool= [itemfori, iteminenumerate(itempool) ifinotinitem_indexes_to_remove]
forplayerinmultiworld.player_ids:
player_local=early_local_rest_items[player]
fill_restrictive(multiworld, base_state,
[locforlocinearly_locationsifloc.player==player],
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
ifplayer_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations= [locforlocinearly_locationsifnotloc.item]
fill_restrictive(multiworld, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items")
early_locations+=early_priority_locations
forplayerinmultiworld.player_ids:
player_local=early_local_prog_items[player]
fill_restrictive(multiworld, base_state,
[locforlocinearly_locationsifloc.player==player],
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
ifplayer_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations= [locforlocinearly_locationsifnotloc.item]
fill_restrictive(multiworld, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
name="Early Progression")
unplaced_early_items=early_rest_items+early_prog_items
ifunplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
f"{unplaced_early_items} early.")
itempool+=unplaced_early_items
fill_locations.extend(early_locations)
multiworld.random.shuffle(fill_locations)
returnfill_locations, itempool
defdistribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] ="swap") ->None:
assertall(item.locationisNoneforiteminmultiworld.itempool), (
"At the start of distribute_items_restrictive, "
"there are items in the multiworld itempool that are already placed on locations:\n"
f"{[(item.location, item) foriteminmultiworld.itempoolifitem.locationisnotNone]}"
)
fill_locations=sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations)
# get items to distribute
itempool=sorted(multiworld.itempool)
multiworld.random.shuffle(itempool)
fill_locations, itempool=distribute_early_items(multiworld, fill_locations, itempool)
progitempool: typing.List[Item] = []
usefulitempool: typing.List[Item] = []
filleritempool: typing.List[Item] = []
foriteminitempool:
ifitem.advancement:
progitempool.append(item)
elifitem.useful:
usefulitempool.append(item)
else:
filleritempool.append(item)
call_all(multiworld, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] forloc_typeinLocationProgressType}
forlocinfill_locations:
locations[loc.progress_type].append(loc)
prioritylocations=locations[LocationProgressType.PRIORITY]
defaultlocations=locations[LocationProgressType.DEFAULT]
excludedlocations=locations[LocationProgressType.EXCLUDED]
# can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
lock_later= []
defmark_for_locking(location: Location):
nonlocallock_later
lock_later.append(location)
single_player=multiworld.players==1andnotmultiworld.groups
ifprioritylocations:
regular_progression= []
deprioritized_progression= []
foriteminprogitempool:
ifitem.deprioritized:
deprioritized_progression.append(item)
else:
regular_progression.append(item)
# "priority fill"
# try without deprioritized items in the mix at all. This means they need to be collected into state first.
priority_fill_state=sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_fill_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True)
ifprioritylocationsandregular_progression:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
# deprioritized items are still not in the mix, so they need to be collected into state first.
# allow_partial should only be set if there is deprioritized progression to fall back on.
priority_retry_state=sweep_from_pool(multiworld.state, deprioritized_progression)
fill_restrictive(multiworld, priority_retry_state, prioritylocations, regular_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False,
allow_partial=bool(deprioritized_progression))
ifprioritylocationsanddeprioritized_progression:
# There are no more regular progression items that can be placed on any priority locations.
# We'd still prefer to place deprioritized progression items on priority locations over filler items.
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_2_state=sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_2_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 2", one_item_per_player=True, allow_partial=True)
ifprioritylocationsanddeprioritized_progression:
# retry with deprioritized items AND without one_item_per_player optimisation
# Since we're leaving out the remaining regular progression now, we need to collect it into state first.
priority_retry_3_state=sweep_from_pool(multiworld.state, regular_progression)
fill_restrictive(multiworld, priority_retry_3_state, prioritylocations, deprioritized_progression,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry 3", one_item_per_player=False)
# restore original order of progitempool
progitempool[:] = [itemforiteminprogitempoolifnotitem.location]
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations=prioritylocations+defaultlocations
ifprogitempool:
# "advancement/progression fill"
maximum_exploration_state=sweep_from_pool(multiworld.state)
ifpanic_method=="swap":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player)
elifpanic_method=="raise":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
elifpanic_method=="start_inventory":
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player)
ifprogitempool:
foriteminprogitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
filleritempool.append(multiworld.worlds[item.player].create_filler())
logging.warning(f"{len(progitempool)} items moved to start inventory,"
f" due to failure in Progression fill step.")
progitempool[:] = []
else:
raiseValueError(f"Generator Panic Method {panic_method} not recognized.")
ifprogitempool:
raiseFillError(
f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.\n"
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
multiworld=multiworld,
)
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
forlocationinlock_later:
iflocation.item:
location.locked=True
delmark_for_locking, lock_later
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
ifexcludedlocations:
raiseFillError(
f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than excludable items.",
multiworld=multiworld,
)
restitempool=filleritempool+usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool,
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
unplaced=restitempool
unfilled=defaultlocations
ifunplacedorunfilled:
logging.warning(
f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}")
items_counter=Counter(location.item.playerforlocationinmultiworld.get_filled_locations())
locations_counter=Counter(location.playerforlocationinmultiworld.get_locations())
items_counter.update(item.playerforiteminunplaced)
print_data= {"items": items_counter, "locations": locations_counter}
logging.info(f"Per-Player counts: {print_data})")
more_locations=locations_counter-items_counter
more_items=items_counter-locations_counter
forplayerinmultiworld.player_ids:
ifmore_locations[player]:
logging.error(
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
elifmore_items[player]:
logging.warning(
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
ifunfilled:
raiseFillError(
f"Unable to fill all locations.\n"+
f"Unfilled locations({len(unfilled)}): {unfilled}"
)
else:
logging.warning(
f"Unable to place all items.\n"+
f"Unplaced items({len(unplaced)}): {unplaced}"
)
defflood_items(multiworld: MultiWorld) ->None:
# get items to distribute
multiworld.random.shuffle(multiworld.itempool)
itempool=multiworld.itempool
progress_done=False
# sweep once to pick up preplaced items
multiworld.state.sweep_for_advancements()
# fill multiworld from top of itempool while we can
whilenotprogress_done:
location_list=multiworld.get_unfilled_locations()
multiworld.random.shuffle(location_list)
spot_to_fill=None
forlocationinlocation_list:
iflocation.can_fill(multiworld.state, itempool[0]):
spot_to_fill=location
break
ifspot_to_fill:
item=itempool.pop(0)
multiworld.push_item(spot_to_fill, item, True)
continue
# ran out of spots, check if we need to step in and correct things
iflen(multiworld.get_reachable_locations()) ==len(multiworld.get_locations()):
progress_done=True
continue
# need to place a progress item instead of an already placed item, find candidate
item_to_place=None
candidate_item_to_place=None
foriteminitempool:
ifitem.advancement:
candidate_item_to_place=item
ifmultiworld.unlocks_new_location(item):
item_to_place=item
break
# we might be in a situation where all new locations require multiple items to reach.
# If that is the case, just place any advancement item we've found and continue trying
ifitem_to_placeisNone:
ifcandidate_item_to_placeisnotNone:
item_to_place=candidate_item_to_place
else:
raiseFillError('No more progress items left to place.', multiworld=multiworld)
# find item to replace with progress item
location_list=multiworld.get_reachable_locations()
multiworld.random.shuffle(location_list)
forlocationinlocation_list:
iflocation.itemisnotNoneandnotlocation.item.advancement:
# safe to replace
replace_item=location.item
replace_item.location=None
itempool.append(replace_item)
multiworld.push_item(location, item_to_place, True)
itempool.remove(item_to_place)
break
defbalance_multiworld_progression(multiworld: MultiWorld) ->None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
# Define a threshold value based on the player with the most available locations.
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
player: multiworld.worlds[player].options.progression_balancing/100
forplayerinmultiworld.player_ids
ifmultiworld.worlds[player].options.progression_balancing>0
}
ifnotbalanceable_players:
logging.info("Skipping multiworld progression balancing.")
else:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.debug(balanceable_players)
state: CollectionState=CollectionState(multiworld)
checked_locations: typing.Set[Location] =set()
unchecked_locations: typing.Set[Location] =set(multiworld.get_locations())
total_locations_count: typing.Counter[int] =Counter(
location.player
forlocationinmultiworld.get_locations()
ifnotlocation.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
forplayerinmultiworld.player_ids
iftotal_locations_count[player] andlen(multiworld.get_filled_locations(player)) !=0
}
balanceable_players= {
player: balanceable_players[player]
forplayerinbalanceable_players
iftotal_locations_count[player]
}
sphere_num: int=1
moved_item_count: int=0
defget_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) ->typing.Set[Location]:
return {locforlocinlocationsifsphere_state.can_reach(loc)}
defitem_percentage(player: int, num: int) ->float:
returnnum/total_locations_count[player]
# If there are no locations that aren't locked, there's no point in attempting to balance progression.
iflen(total_locations_count) ==0:
return
whileTrue:
# Gather non-locked locations.
# This ensures that only shuffled locations get counted for progression balancing,
# i.e. the items the players will be checking.
sphere_locations=get_sphere_locations(state, unchecked_locations)
forlocationinsphere_locations:
unchecked_locations.remove(location)
ifnotlocation.locked:
reachable_locations_count[location.player] +=1
logging.debug(f"Sphere {sphere_num}")
logging.debug(f"Reachable locations: {reachable_locations_count}")
debug_percentages= {
player: round(item_percentage(player, num), 2)
forplayer, numinreachable_locations_count.items()
}
logging.debug(f"Reachable percentages: {debug_percentages}\n")
sphere_num+=1
ifchecked_locations:
max_percentage=max(map(lambdap: item_percentage(p, reachable_locations_count[p]),
reachable_locations_count))
threshold_percentages= {
player: max_percentage*balanceable_players[player]
forplayerinbalanceable_players
}
logging.debug(f"Thresholds: {threshold_percentages}")
balancing_players= {
player
forplayer, reachablesinreachable_locations_count.items()
if (playerinthreshold_percentages
anditem_percentage(player, reachables) <threshold_percentages[player])
}
ifbalancing_players:
balancing_state=state.copy()
balancing_unchecked_locations=unchecked_locations.copy()
balancing_reachables=reachable_locations_count.copy()
balancing_sphere=sphere_locations.copy()
candidate_items: typing.Dict[int, typing.Set[Location]] =collections.defaultdict(set)
whileTrue:
# Check locations in the current sphere and gather progression items to swap earlier
forlocationinbalancing_sphere:
iflocation.advancement:
balancing_state.collect(location.item, True, location)
player=location.item.player
# only replace items that end up in another player's world
if (notlocation.lockedandnotlocation.item.skip_in_prog_balancingand
playerinbalancing_playersand
location.player!=playerand
location.progress_type!=LocationProgressType.PRIORITY):
candidate_items[player].add(location)
logging.debug(f"Candidate item: {location.name}, {location.item.name}")
balancing_sphere=get_sphere_locations(balancing_state, balancing_unchecked_locations)
forlocationinbalancing_sphere:
balancing_unchecked_locations.remove(location)
ifnotlocation.locked:
balancing_reachables[location.player] +=1
ifmultiworld.has_beaten_game(balancing_state) orall(
item_percentage(player, reachables) >=threshold_percentages[player]
forplayer, reachablesinbalancing_reachables.items()
ifplayerinthreshold_percentages):
break
elifnotbalancing_sphere:
raiseRuntimeError("Not all required items reachable. Something went terribly wrong here.")
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] =collections.defaultdict(set)
forlinunchecked_locations:
iflnotinbalancing_unchecked_locations:
unlocked_locations[l.player].add(l)
items_to_replace: typing.List[Location] = []
forplayerinbalancing_players:
locations_to_test=unlocked_locations[player]
items_to_test=list(candidate_items[player])
items_to_test.sort()
multiworld.random.shuffle(items_to_test)
whileitems_to_test:
testing=items_to_test.pop()
reducing_state=state.copy()
forlocationinitertools.chain((
lforlinitems_to_replace
ifl.item.player==player
), items_to_test):
reducing_state.collect(location.item, True, location)
reducing_state.sweep_for_advancements(locations=locations_to_test)
ifmultiworld.has_beaten_game(balancing_state):
ifnotmultiworld.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere=get_sphere_locations(reducing_state, locations_to_test)
p=item_percentage(player, reachable_locations_count[player] +len(reduced_sphere))
ifp<threshold_percentages[player]:
items_to_replace.append(testing)
old_moved_item_count=moved_item_count
# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations=sorted(lforlinchecked_locationsifnotl.advancementandnotl.locked)
multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
multiworld.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
whilereplacement_locationsanditems_to_replace:
old_location=items_to_replace.pop()
fori, new_locationinenumerate(replacement_locations):
ifnew_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
replacement_locations.pop(i)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count+=1
state.collect(new_location.item, True, new_location)
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")
ifold_moved_item_count<moved_item_count:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked= {freshforplayerinbalancing_playersforfreshinunlocked_locations[player]}
forlocationinget_sphere_locations(state, unlocked):
unchecked_locations.remove(location)
ifnotlocation.locked:
reachable_locations_count[location.player] +=1
sphere_locations.add(location)
forlocationinsphere_locations:
iflocation.advancement:
state.collect(location.item, True, location)
checked_locations|=sphere_locations
ifmultiworld.has_beaten_game(state):
break
elifnotsphere_locations:
logging.warning("Progression Balancing ran out of paths.")
break
defswap_location_item(location_1: Location, location_2: Location, check_locked: bool=True) ->None:
"""Swaps Items of locations. Does NOT swap flags like shop_slot or locked, but does swap event"""
ifcheck_locked:
iflocation_1.locked:
logging.warning(f"Swapping {location_1}, which is marked as locked.")
iflocation_2.locked:
logging.warning(f"Swapping {location_2}, which is marked as locked.")
location_2.item, location_1.item=location_1.item, location_2.item
location_1.item.location=location_1
location_2.item.location=location_2
defparse_planned_blocks(multiworld: MultiWorld) ->dict[int, list[PlandoItemBlock]]:
defwarn(warning: str, force: bool|str) ->None:
ifisinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
deffailed(warning: str, force: bool|str) ->None:
ifforceisTrue:
raiseException(warning)
else:
warn(warning, force)
world_name_lookup=multiworld.world_name_lookup
plando_blocks: dict[int, list[PlandoItemBlock]] =dict()
player_ids: set[int] =set(multiworld.player_ids)
forplayerinplayer_ids:
plando_blocks[player] = []
forblockinmultiworld.worlds[player].options.plando_items:
new_block: PlandoItemBlock=PlandoItemBlock(player, block.from_pool, block.force)
target_world=block.world
iftarget_worldisFalseormultiworld.players==1: # target own world
worlds: set[int] = {player}
eliftarget_worldisTrue: # target any worlds besides own
worlds=set(multiworld.player_ids) - {player}
eliftarget_worldisNone: # target all worlds
worlds=set(multiworld.player_ids)
eliftype(target_world) ==list: # list of target worlds
worlds=set()
forlisted_worldintarget_world:
iflisted_worldnotinworld_name_lookup:
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
block.force)
continue
worlds.add(world_name_lookup[listed_world])
eliftype(target_world) ==int: # target world by slot number
iftarget_worldnotinrange(1, multiworld.players+1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block.force)
continue
worlds= {target_world}
else: # target world by slot name
iftarget_worldnotinworld_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force)
continue
worlds= {world_name_lookup[target_world]}
new_block.worlds=worlds
items: list[str] |dict[str, typing.Any] =block.items
ifisinstance(items, dict):
item_list: list[str] = []
forkey, valueinitems.items():
ifvalueisTrue:
value=multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list+= [key] *value
items=item_list
new_block.items=items
locations: list[str] =block.locations
ifisinstance(locations, str):
locations= [locations]