Skip to content
28 changes: 27 additions & 1 deletion docs/making/items.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ A name cannot contain the characters `:` or `|`, and it's not recommended to use

The valid classifications are `"trap"`, `"filler"`, `"useful"`, `"progression"`, `"progression_skip_balancing"`. See [here](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md#items) for details on what each means.


## Categories

Having all your items in the "(No Category)" section is messy and hard to read. We can organize them by adding categories.
Expand Down Expand Up @@ -59,6 +58,33 @@ Most games have non-unique items, yours is probably no different.

Our game has seven oversized novelty keys. Not really much more to explain here.

## Classification Count

This special property let you manually override the counts of an item per classification.

With it you can specify that there should be some `progression` copies of an item while you have `useful` copies too

It can also be used to create multi classification items using a plus (`+`) sign eg. `"progression + useful"`

### Important to note that using `classification_count` in an item make Manual ignore the value of the `count` property and/or any classification properties in said item

*AKA properties like `"count"`, `"useful"`, `"progression"`, `"progression_skip_balancing"` or `"trap"` will be ignored in favour of classification_count*

```json
{
"name": "Jill",
"category": [
"Characters",
"Left Side"
],
"classification_count": {
"useful + progression": 1,
"useful": 1
},
"_comment": "In this example a copy of the 'Jill' item will be created that is useful & progression and another will be created just useful"
},
```

## Early items

Sometimes an item is very important, and you really don't want to leave it up to progression balancing.
Expand Down
14 changes: 14 additions & 0 deletions schemas/Manual.items.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@
"type": ["boolean", "integer"],
"default": false
},
"classification_count": {
"description": "(Optional) (Advanced) A dictionary of how many copy of certain type this item has. \nWhere the properties keys must be the string name of a type ('progression') or an integer/binary representation of its type(s) ('6' or '0b0110') \nIt can also be a concatenated form of those using a + eg. 'progression + useful'",
"type": "object",
"patternProperties": {
"^.+$": {
"anyOf": [
{
"type": "integer",
"description": "A Count of how many copy of this item with this type will be in generation. Must be 'name':integer \neg. \"useful\": 10"
}
]
}
}
},
"id": {
"description": "(Optional) Skips the item ID forward to the given value.\nThis can be used to provide buffer space for future items.",
"type": "integer"
Expand Down
81 changes: 74 additions & 7 deletions src/DataValidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@
import json
from worlds.AutoWorld import World
from BaseClasses import MultiWorld, ItemClassification
from typing import Any


class ValidationError(Exception):
pass

class DataValidation():
game_table = {}
item_table = []
location_table = []
region_table = {}
game_table: dict[str, Any] = {}
item_table: list[dict[str, Any]] = []
location_table: list[dict[str, Any]] = []
region_table: dict[str, Any] = {}


@staticmethod
Expand Down Expand Up @@ -176,17 +177,79 @@ def checkRegionNamesInLocations():
if not region_exists:
raise ValidationError("Region %s is set for location %s, but the region is misspelled or does not exist." % (location["region"], location["name"]))

@staticmethod
def checkItemsHasValidClassificationCount():
for item in DataValidation.item_table:
if not item.get("classification_count"):
continue
for cat, count in item["classification_count"].items():
cat = str(cat)
if count == 0:
continue
try:
def stringCheck(string: str):
if string.isdigit():
ItemClassification(int(string))
elif string.startswith('0b'):
ItemClassification(int(string, base=0))
else:
ItemClassification[string]

if "+" in cat:
for substring in cat.split("+"):
stringCheck(substring.strip())

else:
stringCheck(cat)

except KeyError as ex:
raise ValidationError(f"Item '{item['name']}''s classification_count '{cat}' is misspelled or does not exist.\n Valid names are {', '.join(ItemClassification.__members__.keys())} \n\n{type(ex).__name__}:{ex}")
except Exception as ex:
raise ValidationError(f"Item '{item['name']}''s classification_count '{cat}' was improperly defined\n\n{type(ex).__name__}:{ex}")

@staticmethod
def checkItemsThatShouldBeRequired():
for item in DataValidation.item_table:
# if the item is already progression, no need to check
if "progression" in item and item["progression"]:
if item.get("progression"):
continue

# progression_skip_balancing is also progression, so no check needed
if "progression_skip_balancing" in item and item["progression_skip_balancing"]:
if item.get("progression_skip_balancing"):
continue

# if any of the advanced type is already progression then no check needed
if item.get("classification_count"):
has_progression = False
for cat, count in item["classification_count"].items():
cat = str(cat)
if count == 0:
continue
try:
def stringCheck(string: str) -> ItemClassification:
if string.isdigit():
true_class = ItemClassification(int(string))
elif string.startswith('0b'):
true_class = ItemClassification(int(string, base=0))
else:
true_class = ItemClassification[string]
return true_class

if "+" in cat:
true_class = ItemClassification.filler
for substring in cat.split("+"):
true_class |= stringCheck(substring.strip())
else:
true_class = stringCheck(cat)

except:
# Skip since this validation error is dealt with in checkItemsHasValidClassificationCount
true_class = ItemClassification.filler
if ItemClassification.progression in true_class:
has_progression = True
break

if has_progression:
continue
# check location requires for the presence of item name
for location in DataValidation.location_table:
if "requires" not in location:
Expand Down Expand Up @@ -464,6 +527,10 @@ def runGenerationDataValidation(cls) -> None:
try: DataValidation.checkRegionNamesInLocations()
except ValidationError as e: validation_errors.append(e)

# check that any classification_count used in items are valid
try: DataValidation.checkItemsHasValidClassificationCount()
except ValidationError as e: validation_errors.append(e)

# check that items that are required by locations and regions are also marked required
try: DataValidation.checkItemsThatShouldBeRequired()
except ValidationError as e: validation_errors.append(e)
Expand Down
12 changes: 4 additions & 8 deletions src/Helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@

from BaseClasses import MultiWorld, Item
from enum import IntEnum
from typing import Optional, List, TYPE_CHECKING, Union, get_args, get_origin, Any
from typing import Optional, List, Union, get_args, get_origin, Any
from types import GenericAlias
from worlds.AutoWorld import World
from .hooks.Helpers import before_is_category_enabled, before_is_item_enabled, before_is_location_enabled

if TYPE_CHECKING:
from .Items import ManualItem
from .Locations import ManualLocation

# blatantly copied from the minecraft ap world because why not
def load_data_file(*args) -> dict:
fname = "/".join(["data", *args])
Expand Down Expand Up @@ -87,7 +83,7 @@ def is_item_name_enabled(multiworld: MultiWorld, player: int, item_name: str) ->

return is_item_enabled(multiworld, player, item)

def is_item_enabled(multiworld: MultiWorld, player: int, item: "ManualItem") -> bool:
def is_item_enabled(multiworld: MultiWorld, player: int, item: dict[str, Any]) -> bool:
"""Check if an item has been disabled by a yaml option."""
hook_result = before_is_item_enabled(multiworld, player, item)
if hook_result is not None:
Expand All @@ -103,15 +99,15 @@ def is_location_name_enabled(multiworld: MultiWorld, player: int, location_name:

return is_location_enabled(multiworld, player, location)

def is_location_enabled(multiworld: MultiWorld, player: int, location: "ManualLocation") -> bool:
def is_location_enabled(multiworld: MultiWorld, player: int, location: dict[str, Any]) -> bool:
"""Check if a location has been disabled by a yaml option."""
hook_result = before_is_location_enabled(multiworld, player, location)
if hook_result is not None:
return hook_result

return _is_manualobject_enabled(multiworld, player, location)

def _is_manualobject_enabled(multiworld: MultiWorld, player: int, object: Any) -> bool:
def _is_manualobject_enabled(multiworld: MultiWorld, player: int, object: dict[str, Any]) -> bool:
"""Internal method: Check if a Manual Object has any category disabled by a yaml option.
\nPlease use the proper is_'item/location'_enabled or is_'item/location'_name_enabled methods instead.
"""
Expand Down
35 changes: 26 additions & 9 deletions src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging
import os
import json
from typing import Callable, Optional, Counter
from typing import Callable, Optional, Counter, Any
import webbrowser

import Utils
Expand Down Expand Up @@ -74,7 +74,7 @@ class ManualWorld(World):
def get_filler_item_name(self) -> str:
return hook_get_filler_item_name(self, self.multiworld, self.player) or self.filler_item_name

def interpret_slot_data(self, slot_data: dict[str, any]):
def interpret_slot_data(self, slot_data: dict[str, Any]):
#this is called by tools like UT
if not slot_data:
return False
Expand Down Expand Up @@ -126,11 +126,15 @@ def create_items(self):
if item.get("trap"):
traps.append(name)

if "category" in item:
if not is_item_enabled(self.multiworld, self.player, item):
item_count = 0
if not is_item_enabled(self.multiworld, self.player, item):
items_config[name] = 0

items_config[name] = item_count
else:
if item.get("classification_count"):
items_config[name] = item["classification_count"]

else:
items_config[name] = item_count

items_config = before_create_items_all(items_config, self, self.multiworld, self.player)

Expand All @@ -150,10 +154,23 @@ def create_items(self):
try:
if isinstance(cat, int):
true_class = ItemClassification(cat)
elif cat.startswith('0b'):
true_class = ItemClassification(int(cat, base=0))
else:
true_class = ItemClassification[cat]
def stringCheck(string: str) -> ItemClassification:
if string.isdigit():
true_class = ItemClassification(int(string))
elif string.startswith('0b'):
true_class = ItemClassification(int(string, base=0))
else:
true_class = ItemClassification[string]
return true_class

if "+" in cat:
true_class = ItemClassification.filler
for substring in cat.split("+"):
true_class |= stringCheck(substring.strip())

else:
true_class = stringCheck(cat)
except Exception as ex:
raise Exception(f"Item override '{cat}' for {name} improperly defined\n\n{type(ex).__name__}:{ex}")

Expand Down
5 changes: 3 additions & 2 deletions src/data/items.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"Left Side"
],
"value": {"star": 5, "coins": 3},
"progression": true
"classification_count": {"useful + progression": 1, "useful": 1},
"_comment": "In this example a copy of the 'Jill' item will be created that is useful & progression and another will be created just useful"
},
{
"name": "Shuma-Gorath",
Expand Down Expand Up @@ -406,4 +407,4 @@
"progression": true
} ]

}
}
9 changes: 3 additions & 6 deletions src/hooks/Helpers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
from typing import Optional, TYPE_CHECKING
from typing import Optional, Any
from BaseClasses import MultiWorld, Item, Location

if TYPE_CHECKING:
from ..Items import ManualItem
from ..Locations import ManualLocation

# Use this if you want to override the default behavior of is_option_enabled
# Return True to enable the category, False to disable it, or None to use the default behavior
Expand All @@ -12,10 +9,10 @@ def before_is_category_enabled(multiworld: MultiWorld, player: int, category_nam

# Use this if you want to override the default behavior of is_option_enabled
# Return True to enable the item, False to disable it, or None to use the default behavior
def before_is_item_enabled(multiworld: MultiWorld, player: int, item: "ManualItem") -> Optional[bool]:
def before_is_item_enabled(multiworld: MultiWorld, player: int, item: dict[str, Any]) -> Optional[bool]:
return None

# Use this if you want to override the default behavior of is_option_enabled
# Return True to enable the location, False to disable it, or None to use the default behavior
def before_is_location_enabled(multiworld: MultiWorld, player: int, location: "ManualLocation") -> Optional[bool]:
def before_is_location_enabled(multiworld: MultiWorld, player: int, location: dict[str, Any]) -> Optional[bool]:
return None