Skip to content
84 changes: 84 additions & 0 deletions schemas/Manual.events.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/ManualForArchipelago/Manual/main/schemas/Manual.events.schema.json",
"description": "Schema for Manual's events.json",
"type": ["array", "object"],
"items": {
"$ref": "#/definitions/Events"
},
"properties": {
"$schema": {
"type":"string",
"description": "The schema to verify this document against."
},
"data": {
"description": "List of events for this apworld",
"type": "array",
"items": {"$ref": "#/definitions/Events"}
},
"_comment": {"$ref": "#/definitions/comment"}
},
"definitions": {
"Location": {
"type": "object",
"properties": {
"name": {
"description": "The name of the event. Event names do not need to be unique.",
"type": "string"
},
"category": {
"description": "(Optional) A list of categories to be applied to this event.",
"type": ["string", "array"],
"items": {
"type": "string"
},
"uniqueItems": true
},
"copy_location": {
"description": "(Optional) Copies all information from the named location.",
"type": "string"
},
"requires": {
"description": "(Optional) A boolean logic string that describes the required items, counts, etc. needed to trigger this event.",
"type": [ "string", "array" ],
"items": {
"$ref": "#/definitions/Require"
},
"uniqueItems": true
},
"region": {
"description": "(Optional) The name of the region this event is part of.",
"type": "string"
},
"visible": {
"description": "(Optional) Should this event be visible in the client?",
"type": "boolean",
"default": false
},
"_comment": {"$ref": "#/definitions/comment"}
}
},
"Require": {
"type": ["string", "array", "object"],
"items": {"type": ["string","array","object"]},
"properties": {
"or": {
"description": "alternate to previous property",
"type": "array",
"items": {
"type":"string",
"description": "Alternate item"
}
}
}
},
"comment": {
"description": "(Optional) Does nothing, Its mainly here for Dev notes for future devs to understand your logic",
"type": ["string", "array"],
"items": {
"description": "A line of comment",
"type":"string"
}
}
}
}
5 changes: 5 additions & 0 deletions src/Data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .hooks.Data import \
after_load_game_file, \
after_load_item_file, after_load_location_file, \
after_load_event_file, \
after_load_region_file, after_load_category_file, \
after_load_option_file, after_load_meta_file

Expand Down Expand Up @@ -39,6 +40,7 @@ def load(self):
game_table = ManualFile('game.json', dict).load() #dict
item_table = convert_to_list(ManualFile('items.json', list).load(), 'data') #list
location_table = convert_to_list(ManualFile('locations.json', list).load(), 'data') #list
event_table = convert_to_list(ManualFile('events.json', list).load(), 'data') #list
region_table = ManualFile('regions.json', dict).load() #dict
category_table = ManualFile('categories.json', dict).load() #dict
option_table = ManualFile('options.json', dict).load() #dict
Expand All @@ -52,6 +54,7 @@ def load(self):
game_table = after_load_game_file(game_table)
item_table = after_load_item_file(item_table)
location_table = after_load_location_file(location_table)
event_table = after_load_event_file(event_table)
region_table = after_load_region_file(region_table)
category_table = after_load_category_file(category_table)
option_table = after_load_option_file(option_table)
Expand All @@ -60,7 +63,9 @@ def load(self):
# seed all of the tables for validation
DataValidation.game_table = game_table
DataValidation.item_table = item_table
DataValidation.item_table_with_events = item_table + event_table
DataValidation.location_table = location_table
DataValidation.location_table_with_events = location_table + event_table
DataValidation.region_table = region_table

validation_errors = []
Expand Down
20 changes: 11 additions & 9 deletions src/DataValidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ class ValidationError(Exception):
class DataValidation():
game_table = {}
item_table = []
item_table_with_events = []
location_table = []
location_table_with_events = []
region_table = {}


@staticmethod
def checkItemNamesInLocationRequires():
for location in DataValidation.location_table:
for location in DataValidation.location_table_with_events:
if "requires" not in location:
continue

Expand All @@ -39,7 +41,7 @@ def checkItemNamesInLocationRequires():
if len(item_parts) > 1:
item_name = item_parts[0]

item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 0
item_exists = len([item["name"] for item in DataValidation.item_table_with_events if item["name"] == item_name]) > 0

if not item_exists:
raise ValidationError("Item %s is required by location %s but is misspelled or does not exist." % (item_name, location["name"]))
Expand All @@ -60,7 +62,7 @@ def checkItemNamesInLocationRequires():
if len(or_item_parts) > 1:
or_item_name = or_item_parts[0]

item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == or_item_name]) > 0
item_exists = len([item["name"] for item in DataValidation.item_table_with_events if item["name"] == or_item_name]) > 0

if not item_exists:
raise ValidationError("Item %s is required by location %s but is misspelled or does not exist." % (or_item_name, location["name"]))
Expand All @@ -71,7 +73,7 @@ def checkItemNamesInLocationRequires():
if len(item_parts) > 1:
item_name = item_parts[0]

item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 0
item_exists = len([item["name"] for item in DataValidation.item_table_with_events if item["name"] == item_name]) > 0

if not item_exists:
raise ValidationError("Item %s is required by location %s but is misspelled or does not exist." % (item_name, location["name"]))
Expand Down Expand Up @@ -102,7 +104,7 @@ def checkItemNamesInRegionRequires():
if len(item_parts) > 1:
item_name = item_parts[0]

item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 0
item_exists = len([item["name"] for item in DataValidation.item_table_with_events if item["name"] == item_name]) > 0

if not item_exists:
raise ValidationError("Item %s is required by region %s but is misspelled or does not exist." % (item_name, region_name))
Expand All @@ -123,7 +125,7 @@ def checkItemNamesInRegionRequires():
if len(or_item_parts) > 1:
or_item_name = or_item_parts[0]

item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == or_item_name]) > 0
item_exists = len([item["name"] for item in DataValidation.item_table_with_events if item["name"] == or_item_name]) > 0

if not item_exists:
raise ValidationError("Item %s is required by region %s but is misspelled or does not exist." % (or_item_name, region_name))
Expand All @@ -134,14 +136,14 @@ def checkItemNamesInRegionRequires():
if len(item_parts) > 1:
item_name = item_parts[0]

item_exists = len([item["name"] for item in DataValidation.item_table if item["name"] == item_name]) > 0
item_exists = len([item["name"] for item in DataValidation.item_table_with_events if item["name"] == item_name]) > 0

if not item_exists:
raise ValidationError("Item %s is required by region %s but is misspelled or does not exist." % (item_name, region_name))

@staticmethod
def checkRegionNamesInLocations():
for location in DataValidation.location_table:
for location in DataValidation.location_table_with_events:
if "region" not in location or location["region"] in ["Menu", "Manual"]:
continue

Expand All @@ -162,7 +164,7 @@ def checkItemsThatShouldBeRequired():
continue

# check location requires for the presence of item name
for location in DataValidation.location_table:
for location in DataValidation.location_table_with_events:
if "requires" not in location:
continue

Expand Down
29 changes: 28 additions & 1 deletion src/Locations.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from BaseClasses import Location
from .Data import location_table
from .Data import location_table, event_table
from .Game import starting_index


Expand Down Expand Up @@ -46,6 +46,7 @@
location_id_to_name: dict[int, str] = {}
location_name_to_location: dict[str, dict] = {}
location_name_groups: dict[str, list[str]] = {}
event_name_to_event: dict[str, list[str]] = {}

for item in location_table:
location_id_to_name[item["id"]] = item["name"]
Expand All @@ -60,6 +61,32 @@
# location_id_to_name[None] = "__Manual Game Complete__"
location_name_to_id = {name: id for id, name in location_id_to_name.items()}

for key, _ in enumerate(event_table):
if "copy_location" in event_table[key]:
event_table[key] = location_name_to_location[event_table[key]["copy_location"]] | event_table[key]

id = 0
for key, event in enumerate(event_table):
if "location_name" in event:
if event["location_name"] in location_name_to_location:
raise Exception(f"Cannot define event {event['location_name']} with the same name as a location.")
event_name_to_event[event_name] = event
else:
event_name = f"{id}_{event['name']}".upper().replace(" ", "_")
while event_name in location_name_to_location:
id += 1
event_name = f"{id}_{event['name']}".upper().replace(" ", "_")
event_name_to_event[event_name] = event
event_name_to_event[event_name]["location_name"] = event_name
event_table[key]["location_name"] = event_name
if 'visible' not in event:
event_name_to_event[event_name]['visible'] = False
event_table[key]['visible'] = False
if 'region' not in event:
event_name_to_event[event_name]['region'] = "Manual"
event_table[key]['region'] = "Manual"
id += 1

######################
# Location classes
######################
Expand Down
37 changes: 35 additions & 2 deletions src/ManualClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ class ManualContext(SuperContext):
last_death_link = 0
deathlink_out = False

visible_events = {}

search_term = ""

colors = {
Expand Down Expand Up @@ -207,6 +209,7 @@ def on_package(self, cmd: str, args: dict):
self.ui.enable_death_link()
self.set_deathlink = True
self.last_death_link = 0
self.visible_events = args['slot_data'].get('visible_events', {})
logger.info(f"Slot data: {args['slot_data']}")

self.ui.build_tracker_and_locations_table()
Expand All @@ -229,6 +232,13 @@ def on_tracker_events(self, events: list[str]):
self.tracker_reachable_events = events
if events:
self.ui.request_update_tracker_and_locations_table(update_highlights=True)

def is_event_visible(self, event_name, category_name):
if event_name not in self.visible_events:
return False
if category_name == "(No Category)" and len(self.visible_events[event_name]) == 0:
return True
return category_name in self.visible_events[event_name]

def handle_connection_loss(self, msg: str) -> None:
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
Expand Down Expand Up @@ -489,6 +499,16 @@ def build_tracker_and_locations_table(self):
if category not in self.listed_items:
self.listed_items[category] = []

for event, categories in self.ctx.visible_events.items():
for category in categories:
category_settings = self.ctx.category_table.get(category) or getattr(AutoWorldRegister.world_types[self.ctx.game], "category_table", {}).get(category, {})
if "hidden" in category_settings and category_settings["hidden"]:
continue
if category not in self.item_categories:
self.item_categories.append(category)
if category not in self.listed_items:
self.listed_items[category] = []


# Items are not received on connect, so don't bother attempting to work with received items here

Expand Down Expand Up @@ -661,8 +681,11 @@ def update_tracker_and_locations_table(self, update_highlights=False):
# Get the item name from the item Label, minus quantity, then do a lookup for count
old_item_text = item.text
item_name = re.sub(r"\s\(\d+\)$", "", item.text)
item_id = self.ctx.item_names_to_id[item_name]
item_count = len(list(i for i in self.ctx.items_received if i.item == item_id))
item_id = self.ctx.item_names_to_id.get(item_name, False)
if item_id:
item_count = len(list(i for i in self.ctx.items_received if i.item == item_id))
else:
item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == item_name))

# if the player is searching for text and the item name doesn't contain it, skip it
if self.ctx.search_term and not self.ctx.search_term.lower() in item_name.lower():
Expand Down Expand Up @@ -720,6 +743,16 @@ def update_tracker_and_locations_table(self, update_highlights=False):
category_count += item_count
category_unique_name_count += 1

for event in sorted(self.ctx.tracker_reachable_events):
if self.ctx.is_event_visible(event, category_name) and event not in self.listed_items[category_name]:
item_count = len(list(i for i in self.ctx.tracker_reachable_events if i == event))
item_text = Label(text="%s (%s)" % (event, item_count),
size_hint=(None, None), height=dp(30), width=dp(400), bold=True)
category_grid.add_widget(item_text)
self.listed_items[category_name].append(event)
category_count += item_count
category_unique_name_count += 1

scrollview_height = 30 * category_unique_name_count

if scrollview_height > 250:
Expand Down
11 changes: 10 additions & 1 deletion src/Regions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from BaseClasses import Entrance, MultiWorld, Region
from BaseClasses import Entrance, MultiWorld, Region, ItemClassification
from .Helpers import is_category_enabled, is_location_enabled
from .Data import region_table
from .Locations import ManualLocation, location_name_to_location
from .Items import ManualItem
from worlds.AutoWorld import World


Expand Down Expand Up @@ -70,3 +71,11 @@ def create_region(world: World, multiworld: MultiWorld, player: int, name: str,

def getConnectionName(entranceName: str, exitName: str):
return entranceName + "To" + exitName

def create_events(world: World, multiworld: MultiWorld, player: int):
for name, event in world.event_name_to_event.items():
region = multiworld.get_region(event.get("region", "Manual"), player)
item = ManualItem(event["name"], ItemClassification.progression, None, player=player)
location = ManualLocation(player, name, None, region)
region.locations.append(location)
location.place_locked_item(item)
11 changes: 8 additions & 3 deletions src/Rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ def findAndRecursivelyExecuteFunctions(requires_list: str, recursionDepth: int =

if require_type == 'category':
category_items = [item for item in world.item_name_to_item.values() if "category" in item and item_name in item["category"]]
category_items += [event for event in world.event_name_to_event.values() if "category" in event and item_name in event["category"]]
category_items_counts = sum([items_counts.get(category_item["name"], 0) for category_item in category_items])
if item_count.lower() == 'all':
item_count = category_items_counts
Expand Down Expand Up @@ -298,11 +299,15 @@ def fullRegionCheck(state: CollectionState, region=regionMap[region]):
add_rule(exit, lambda state, rule={"requires": exit_rules[e]}: fullLocationOrRegionCheck(state, rule))

# Location access rules
for location in world.location_table:
if location["name"] not in used_location_names:
for location in (world.location_table + world.event_table):
if "location_name" in location:
name = location["location_name"]
elif location["name"] not in used_location_names:
continue
else:
name = location["name"]

locFromWorld = multiworld.get_location(location["name"], player)
locFromWorld = multiworld.get_location(name, player)

locationRegion = regionMap[location["region"]] if "region" in location else None

Expand Down
Loading