Skip to content

Commit bc36a91

Browse files
authored
Per Classification Count aka Item types config not just for hooks devs (#161)
* add support for item config (from before_create_items_all hook) but in items.json * fix item arguments typing in helpers.py * add advanced_types to schema and make sure int works * fix and add datavalidation support for advanced_types * advanced_types: add support for concatenated classifications * complex_count: renamed advanced_types to complex_count since this makes a lot more sense * complex_count: remove unneeded recursion * classification_count: renamed complex_count to classification_count and added docs
1 parent b71af4c commit bc36a91

File tree

7 files changed

+151
-33
lines changed

7 files changed

+151
-33
lines changed

docs/making/items.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ A name cannot contain the characters `:` or `|`, and it's not recommended to use
2929

3030
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.
3131

32-
3332
## Categories
3433

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

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

61+
## Classification Count
62+
63+
This special property let you manually override the counts of an item per classification.
64+
65+
With it you can specify that there should be some `progression` copies of an item while you have `useful` copies too
66+
67+
It can also be used to create multi classification items using a plus (`+`) sign eg. `"progression + useful"`
68+
69+
### 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
70+
71+
*AKA properties like `"count"`, `"useful"`, `"progression"`, `"progression_skip_balancing"` or `"trap"` will be ignored in favour of classification_count*
72+
73+
```json
74+
{
75+
"name": "Jill",
76+
"category": [
77+
"Characters",
78+
"Left Side"
79+
],
80+
"classification_count": {
81+
"useful + progression": 1,
82+
"useful": 1
83+
},
84+
"_comment": "In this example a copy of the 'Jill' item will be created that is useful & progression and another will be created just useful"
85+
},
86+
```
87+
6288
## Early items
6389

6490
Sometimes an item is very important, and you really don't want to leave it up to progression balancing.

schemas/Manual.items.schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@
9494
"type": ["boolean", "integer"],
9595
"default": false
9696
},
97+
"classification_count": {
98+
"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'",
99+
"type": "object",
100+
"patternProperties": {
101+
"^.+$": {
102+
"anyOf": [
103+
{
104+
"type": "integer",
105+
"description": "A Count of how many copy of this item with this type will be in generation. Must be 'name':integer \neg. \"useful\": 10"
106+
}
107+
]
108+
}
109+
}
110+
},
97111
"id": {
98112
"description": "(Optional) Skips the item ID forward to the given value.\nThis can be used to provide buffer space for future items.",
99113
"type": "integer"

src/DataValidation.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
import json
44
from worlds.AutoWorld import World
55
from BaseClasses import MultiWorld, ItemClassification
6+
from typing import Any
67

78

89
class ValidationError(Exception):
910
pass
1011

1112
class DataValidation():
12-
game_table = {}
13-
item_table = []
14-
location_table = []
15-
region_table = {}
13+
game_table: dict[str, Any] = {}
14+
item_table: list[dict[str, Any]] = []
15+
location_table: list[dict[str, Any]] = []
16+
region_table: dict[str, Any] = {}
1617

1718

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

180+
@staticmethod
181+
def checkItemsHasValidClassificationCount():
182+
for item in DataValidation.item_table:
183+
if not item.get("classification_count"):
184+
continue
185+
for cat, count in item["classification_count"].items():
186+
cat = str(cat)
187+
if count == 0:
188+
continue
189+
try:
190+
def stringCheck(string: str):
191+
if string.isdigit():
192+
ItemClassification(int(string))
193+
elif string.startswith('0b'):
194+
ItemClassification(int(string, base=0))
195+
else:
196+
ItemClassification[string]
197+
198+
if "+" in cat:
199+
for substring in cat.split("+"):
200+
stringCheck(substring.strip())
201+
202+
else:
203+
stringCheck(cat)
204+
205+
except KeyError as ex:
206+
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}")
207+
except Exception as ex:
208+
raise ValidationError(f"Item '{item['name']}''s classification_count '{cat}' was improperly defined\n\n{type(ex).__name__}:{ex}")
209+
179210
@staticmethod
180211
def checkItemsThatShouldBeRequired():
181212
for item in DataValidation.item_table:
182213
# if the item is already progression, no need to check
183-
if "progression" in item and item["progression"]:
214+
if item.get("progression"):
184215
continue
185216

186217
# progression_skip_balancing is also progression, so no check needed
187-
if "progression_skip_balancing" in item and item["progression_skip_balancing"]:
218+
if item.get("progression_skip_balancing"):
188219
continue
189-
220+
# if any of the advanced type is already progression then no check needed
221+
if item.get("classification_count"):
222+
has_progression = False
223+
for cat, count in item["classification_count"].items():
224+
cat = str(cat)
225+
if count == 0:
226+
continue
227+
try:
228+
def stringCheck(string: str) -> ItemClassification:
229+
if string.isdigit():
230+
true_class = ItemClassification(int(string))
231+
elif string.startswith('0b'):
232+
true_class = ItemClassification(int(string, base=0))
233+
else:
234+
true_class = ItemClassification[string]
235+
return true_class
236+
237+
if "+" in cat:
238+
true_class = ItemClassification.filler
239+
for substring in cat.split("+"):
240+
true_class |= stringCheck(substring.strip())
241+
else:
242+
true_class = stringCheck(cat)
243+
244+
except:
245+
# Skip since this validation error is dealt with in checkItemsHasValidClassificationCount
246+
true_class = ItemClassification.filler
247+
if ItemClassification.progression in true_class:
248+
has_progression = True
249+
break
250+
251+
if has_progression:
252+
continue
190253
# check location requires for the presence of item name
191254
for location in DataValidation.location_table:
192255
if "requires" not in location:
@@ -466,6 +529,10 @@ def runGenerationDataValidation(cls) -> None:
466529
try: DataValidation.checkRegionNamesInLocations()
467530
except ValidationError as e: validation_errors.append(e)
468531

532+
# check that any classification_count used in items are valid
533+
try: DataValidation.checkItemsHasValidClassificationCount()
534+
except ValidationError as e: validation_errors.append(e)
535+
469536
# check that items that are required by locations and regions are also marked required
470537
try: DataValidation.checkItemsThatShouldBeRequired()
471538
except ValidationError as e: validation_errors.append(e)

src/Helpers.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,11 @@
66

77
from BaseClasses import MultiWorld, Item
88
from enum import IntEnum
9-
from typing import Optional, List, TYPE_CHECKING, Union, get_args, get_origin, Any
9+
from typing import Optional, List, Union, get_args, get_origin, Any
1010
from types import GenericAlias
1111
from worlds.AutoWorld import World
1212
from .hooks.Helpers import before_is_category_enabled, before_is_item_enabled, before_is_location_enabled
1313

14-
if TYPE_CHECKING:
15-
from .Items import ManualItem
16-
from .Locations import ManualLocation
17-
1814
# blatantly copied from the minecraft ap world because why not
1915
def load_data_file(*args) -> dict:
2016
fname = "/".join(["data", *args])
@@ -87,7 +83,7 @@ def is_item_name_enabled(multiworld: MultiWorld, player: int, item_name: str) ->
8783

8884
return is_item_enabled(multiworld, player, item)
8985

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

104100
return is_location_enabled(multiworld, player, location)
105101

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

112108
return _is_manualobject_enabled(multiworld, player, location)
113109

114-
def _is_manualobject_enabled(multiworld: MultiWorld, player: int, object: Any) -> bool:
110+
def _is_manualobject_enabled(multiworld: MultiWorld, player: int, object: dict[str, Any]) -> bool:
115111
"""Internal method: Check if a Manual Object has any category disabled by a yaml option.
116112
\nPlease use the proper is_'item/location'_enabled or is_'item/location'_name_enabled methods instead.
117113
"""

src/__init__.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import os
44
import json
5-
from typing import Callable, Optional, Counter
5+
from typing import Callable, Optional, Counter, Any
66
import webbrowser
77

88
import Utils
@@ -74,7 +74,7 @@ class ManualWorld(World):
7474
def get_filler_item_name(self) -> str:
7575
return hook_get_filler_item_name(self, self.multiworld, self.player) or self.filler_item_name
7676

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

129-
if "category" in item:
130-
if not is_item_enabled(self.multiworld, self.player, item):
131-
item_count = 0
129+
if not is_item_enabled(self.multiworld, self.player, item):
130+
items_config[name] = 0
132131

133-
items_config[name] = item_count
132+
else:
133+
if item.get("classification_count"):
134+
items_config[name] = item["classification_count"]
135+
136+
else:
137+
items_config[name] = item_count
134138

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

@@ -150,10 +154,23 @@ def create_items(self):
150154
try:
151155
if isinstance(cat, int):
152156
true_class = ItemClassification(cat)
153-
elif cat.startswith('0b'):
154-
true_class = ItemClassification(int(cat, base=0))
155157
else:
156-
true_class = ItemClassification[cat]
158+
def stringCheck(string: str) -> ItemClassification:
159+
if string.isdigit():
160+
true_class = ItemClassification(int(string))
161+
elif string.startswith('0b'):
162+
true_class = ItemClassification(int(string, base=0))
163+
else:
164+
true_class = ItemClassification[string]
165+
return true_class
166+
167+
if "+" in cat:
168+
true_class = ItemClassification.filler
169+
for substring in cat.split("+"):
170+
true_class |= stringCheck(substring.strip())
171+
172+
else:
173+
true_class = stringCheck(cat)
157174
except Exception as ex:
158175
raise Exception(f"Item override '{cat}' for {name} improperly defined\n\n{type(ex).__name__}:{ex}")
159176

src/data/items.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"Left Side"
99
],
1010
"value": {"star": 5, "coins": 3},
11-
"progression": true
11+
"classification_count": {"useful + progression": 1, "useful": 1},
12+
"_comment": "In this example a copy of the 'Jill' item will be created that is useful & progression and another will be created just useful"
1213
},
1314
{
1415
"name": "Shuma-Gorath",
@@ -406,4 +407,4 @@
406407
"progression": true
407408
} ]
408409

409-
}
410+
}

src/hooks/Helpers.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
1-
from typing import Optional, TYPE_CHECKING
1+
from typing import Optional, Any
22
from BaseClasses import MultiWorld, Item, Location
33

4-
if TYPE_CHECKING:
5-
from ..Items import ManualItem
6-
from ..Locations import ManualLocation
74

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

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

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

0 commit comments

Comments
 (0)