Skip to content

Commit

Permalink
Merge pull request Ziktofel#359 from Salzkorn/sc2-next
Browse files Browse the repository at this point in the history
Add index function support to entry rule scope & mission slot next options
  • Loading branch information
Ziktofel authored Dec 3, 2024
2 parents eed1469 + 8002867 commit 12ab538
Show file tree
Hide file tree
Showing 3 changed files with 55 additions and 45 deletions.
9 changes: 5 additions & 4 deletions worlds/sc2/docs/custom_mission_orders_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ These keys will never be used by the generator unless you specify them yourself.

The Beat and Count rules both require a list of scopes. This list accepts addresses towards other parts of the mission order.

The basic form of an address is `<Campaign>/<Layout>/<Mission>`, where `<Campaign>` and `<Layout>` are the definition names (not `display_names`!) of a campaign and a layout within that campaign, and `<Mission>` is the index of a mission slot in that layout. The indices of mission slots are determined by the layout's type.
The basic form of an address is `<Campaign>/<Layout>/<Mission>`, where `<Campaign>` and `<Layout>` are the definition names (not `display_names`!) of a campaign and a layout within that campaign, and `<Mission>` is the index of a mission slot in that layout or an index function for the layout's type. See the section on your layout's type to find valid indices and functions.

If you don't want to point all the way down to a mission slot, you can omit the later parts. `<Campaign>` and `<Campaign>/<Layout>` are valid addresses, and will point to the entire specified campaign or layout.

Expand Down Expand Up @@ -706,8 +706,9 @@ The following example shows ways to access and modify missions:
# This sets the mission at index 1 to be an exit
- index: 1
exit: true
# Indices can be special terms
# Valid terms are 'exits', 'entrances', and 'all'
# Indices can be special index functions
# Valid functions are 'exits', 'entrances', and 'all'
# These are available for all types of layouts
# This takes all exits, including the one set above,
# and turns them into non-exits
- index: exits
Expand Down Expand Up @@ -756,7 +757,7 @@ Layout types have their own means of creating blank spaces in the client, and so
```yaml
next: []
```
Valid values are indices of other missions within the same layout. Note that this does not accept addresses.
Valid values are indices of other missions within the same layout and index functions for the layout's type. Note that this does not accept addresses.

This is the mechanism layout types use to establish mission flow. Overriding this will break the intended order of missions within a type. If you wish to add on to the type's flow rather than replace it, you must manually include the indices intended by the type.

Expand Down
32 changes: 16 additions & 16 deletions worlds/sc2/mission_order/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ class CustomMissionOrder(OptionDict):
Optional("exit"): bool,
Optional("goal"): bool,
Optional("empty"): bool,
Optional("next"): [int],
Optional("next"): [Or(int, str)],
Optional("entry_rules"): [EntryRule],
Optional("mission_pool"): {int},
Optional("difficulty"): Difficulty,
Expand Down Expand Up @@ -274,7 +274,7 @@ def _resolve_special_option(option: str, option_value: Any) -> Any:
else:
return [str(option_value)]

if option == "index":
if option in ["index", "next"]:
# All index values could be ranges
if type(option_value) == list:
# Flatten any nested lists
Expand Down Expand Up @@ -387,20 +387,20 @@ def _get_target_missions(term: str) -> Set[int]:

# Class-agnostic version of AP Options.Range.custom_range
def _custom_range(text: str) -> int:
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {CustomMissionOrder.__name__}")
random_range.sort()
if text.startswith("random-range-low"):
return _triangular(random_range[0], random_range[1], random_range[0])
elif text.startswith("random-range-middle"):
return _triangular(random_range[0], random_range[1])
elif text.startswith("random-range-high"):
return _triangular(random_range[0], random_range[1], random_range[1])
else:
return random.randint(random_range[0], random_range[1])
textsplit = text.split("-")
try:
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {CustomMissionOrder.__name__}")
random_range.sort()
if text.startswith("random-range-low"):
return _triangular(random_range[0], random_range[1], random_range[0])
elif text.startswith("random-range-middle"):
return _triangular(random_range[0], random_range[1])
elif text.startswith("random-range-high"):
return _triangular(random_range[0], random_range[1], random_range[1])
else:
return random.randint(random_range[0], random_range[1])

def _triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
return int(round(random.triangular(lower, end, tri), 0))
Expand Down
59 changes: 34 additions & 25 deletions worlds/sc2/mission_order/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ def dict_to_entry_rule(self, data: Dict[str, Any], start_node: MissionOrderNode,
objects: List[Tuple[MissionOrderNode, str]] = []
for address in data["scope"]:
resolved = self.resolve_address(address, start_node)
objects.append((resolved, address))
objects.extend((obj, address) for obj in resolved)
visual_reqs = [obj.get_visual_requirement(start_node) for (obj, _) in objects]
if "amount" in data:
missions = [mission for (obj, _) in objects for mission in obj.get_missions() if not mission.option_empty]
Expand All @@ -362,7 +362,7 @@ def dict_to_entry_rule(self, data: Dict[str, Any], start_node: MissionOrderNode,
missions.extend(exits)
return BeatMissionsEntryRule(missions, visual_reqs)

def resolve_address(self, address: str, start_node: MissionOrderNode) -> MissionOrderNode:
def resolve_address(self, address: str, start_node: MissionOrderNode) -> List[MissionOrderNode]:
if address.startswith("../") or address == "..":
# Relative address, starts from searching object
cursor = start_node
Expand All @@ -383,14 +383,17 @@ def resolve_address(self, address: str, start_node: MissionOrderNode) -> Mission
if len(result) == 0:
raise ValueError(f"Address \"{address_so_far}\" (from \"{address}\") could not find a {cursor.child_type_name()}.")
if len(result) > 1:
raise ValueError((f"Address \"{address_so_far}\" (from \"{address}\") found more than one {cursor.child_type_name()}s."))
# Layouts are allowed to end with multiple missions via an index function
if type(result[0]) == SC2MOGenMission and address_so_far == address:
return result
raise ValueError((f"Address \"{address_so_far}\" (from \"{address}\") found more than one {cursor.child_type_name()}."))
cursor = result[0]
if cursor == start_node:
raise ValueError(
f"Address \"{address_so_far}\" (from \"{address}\") returned to original object. " +
"This is not allowed to avoid circular requirements."
)
return cursor
return [cursor]

def fill_missions(
self, world: World, locked_missions: List[str],
Expand Down Expand Up @@ -682,19 +685,8 @@ def __init__(self, world: World, parent: ReferenceType[SC2MOGenCampaign], name:
indices: Set[int] = set()
index_terms: List[Union[int, str]] = mission_data["index"]
for term in index_terms:
if type(term) == int:
indices.add(term)
elif term == "entrances":
indices.update(idx for idx in range(len(self.missions)) if self.missions[idx].option_entrance)
elif term == "exits":
indices.update(idx for idx in range(len(self.missions)) if self.missions[idx].option_exit)
elif term == "all":
indices.update(idx for idx in range(len(self.missions)))
else:
result = self.layout_type.parse_index(term)
if result is None:
raise ValueError(f"Layout \"{self.option_name}\" could not resolve mission index term \"{term}\".")
indices.update(result)
result = self.resolve_index_term(term)
indices.update(result)
for idx in indices:
self.missions[idx].update_with_data(mission_data)

Expand All @@ -704,7 +696,7 @@ def __init__(self, world: World, parent: ReferenceType[SC2MOGenCampaign], name:
if mission.option_exit:
self.exits.append(mission)
if mission.option_next is not None:
mission.next = [self.missions[idx] for idx in mission.option_next]
mission.next = [self.missions[idx] for term in mission.option_next for idx in sorted(self.resolve_index_term(term))]

# Set up missions' prev data
for mission in self.missions:
Expand Down Expand Up @@ -809,6 +801,24 @@ def resolve_difficulties(self,
sorted_missions[mission.option_difficulty].append(mission)
return (sorted_missions, fixed_missions)

def resolve_index_term(self, term: Union[str, int], *, ignore_out_of_bounds: bool = True, reject_none: bool = True) -> Union[Set[int], None]:
try:
result = {int(term)}
except ValueError:
if term == "entrances":
result = {idx for idx in range(len(self.missions)) if self.missions[idx].option_entrance}
elif term == "exits":
result = {idx for idx in range(len(self.missions)) if self.missions[idx].option_exit}
elif term == "all":
result = {idx for idx in range(len(self.missions))}
else:
result = self.layout_type.parse_index(term)
if result is None and reject_none:
raise ValueError(f"Layout \"{self.option_name}\" could not resolve mission index term \"{term}\".")
if ignore_out_of_bounds:
result = [index for index in result if index >= 0 and index < len(self.missions)]
return result

def get_parent(self, _address_so_far: str, _full_address: str) -> MissionOrderNode:
if self.parent().option_single_layout_campaign:
parent = self.parent().parent
Expand All @@ -817,13 +827,12 @@ def get_parent(self, _address_so_far: str, _full_address: str) -> MissionOrderNo
return parent()

def search(self, term: str) -> Union[List[MissionOrderNode], None]:
try:
index = int(term)
except ValueError:
return []
if index < 0 or index >= len(self.missions):
indices = self.resolve_index_term(term, reject_none=False)
if indices is None:
# Let the address parser handle the fail case
return []
return [self.missions[index]]
missions = [self.missions[index] for index in sorted(indices)]
return missions

def child_type_name(self) -> str:
return "Mission"
Expand Down Expand Up @@ -871,7 +880,7 @@ class SC2MOGenMission(MissionOrderNode):
option_entrance: bool # whether this mission is unlocked when the layout is unlocked
option_exit: bool # whether this mission is required to beat its parent layout
option_empty: bool # whether this slot contains a mission at all
option_next: Union[None, List[int]] # indices of internally connected missions
option_next: Union[None, List[Union[int, str]]] # indices of internally connected missions
option_entry_rules: List[Dict[str, Any]]
option_difficulty: Difficulty # difficulty pool this mission pulls from
option_mission_pool: Set[int] # Allowed mission IDs for this slot
Expand Down

0 comments on commit 12ab538

Please sign in to comment.