Skip to content

Commit 5bd3d42

Browse files
committed
Merge remote-tracking branch 'origin/main' into client-improvements
# Conflicts: # src/__init__.py
2 parents 7b637d1 + eaeef91 commit 5bd3d42

File tree

15 files changed

+564
-277
lines changed

15 files changed

+564
-277
lines changed

docs/making/game.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
Game.json contains the most fundamental details about your Manual Game, the details about the game itself.
44

55
At its most basic, it needs the name of the game, and name of the creator (you).
6-
It also contains the name of the auto-generated filler item, and details about Starting Inventory (see below for more details)
6+
It also contains the name of the auto-generated filler item, and details about [Starting Inventory](#starting-inventory) and whether [Deathlink](#death-link) is enabled.
77

88
```json
99
{
@@ -84,5 +84,19 @@ This works like the first one, except with categories instead of items. There's
8484
```
8585
Lastly, this gives you three completely random items.
8686

87+
## Death Link
88+
89+
Enabling Deathlink is very simple. Just add `"death_link": true` to your game.json, and you'll get a yaml setting and fancy button in the client.
90+
91+
```json
92+
{
93+
"game": "Snolf",
94+
"creator": "YourNameHere",
95+
"filler_item_name": "Gold Rings",
96+
"death_link": true
97+
}
98+
```
99+
100+
If you turn this on, make sure you have a clear (and documented) understanding of what a death is, when players should send one, and what it means to receive it.
87101

88102
Next: Add your [items](/docs/making/items.md)

schemas/Manual.game.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"$ref": "#/definitions/Sitems"
2929
}
3030
},
31+
"death_link": {
32+
"description": "(Optional) Does your game support Deathlink?",
33+
"type": "boolean"
34+
},
3135
"_comment": {"$ref": "#/definitions/comment"}
3236
},
3337
"required":["game", "filler_item_name"],

schemas/Manual.items.schema.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@
5757
"type": "boolean",
5858
"default": true
5959
},
60+
"value": {
61+
"description": "(Optional) A dictonary of values this item has in the format {\"name\": int,\"otherName\": int} \nUsed with the {ItemValue(Name: int)} in location requires \neg. \"value\": {\"coins\":10} mean this item is worth 10 coins",
62+
"type": "object",
63+
"patternProperties": {
64+
"^.+$": {
65+
"anyOf": [
66+
{
67+
"type": "integer",
68+
"description": "A value that this item has must be 'name':integer \neg. \"coins\": 10"
69+
}
70+
]
71+
}
72+
}
73+
},
6074
"_comment": {"$ref": "#/definitions/comment"}
6175

6276
},

src/DataValidation.py

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import logging
22
import re
33
import json
4+
from worlds.AutoWorld import World
5+
from BaseClasses import MultiWorld, ItemClassification
6+
47

58
class ValidationError(Exception):
69
pass
@@ -192,6 +195,109 @@ def checkItemsThatShouldBeRequired():
192195
if item["name"] in region_requires:
193196
raise ValidationError("Item %s is required by region %s, but the item is not marked as progression." % (item["name"], region_name))
194197

198+
@staticmethod
199+
def _checkLocationRequiresForItemValueWithRegex(values_requested: dict[str, int], requires) -> dict[str, int]:
200+
if isinstance(requires, str) and 'ItemValue' in requires:
201+
for result in re.findall(r'\{ItemValue\(([^:]*)\:([^)]+)\)\}', requires):
202+
value = result[0].lower().strip()
203+
count = int(result[1])
204+
if not values_requested.get(value):
205+
values_requested[value] = count
206+
else:
207+
values_requested[value] = max(values_requested[value], count)
208+
return values_requested
209+
210+
@staticmethod
211+
def checkIfEnoughItemsForValue():
212+
values_available = {}
213+
values_requested = {}
214+
215+
# First find the biggest values required by locations
216+
for location in DataValidation.location_table:
217+
if "requires" not in location:
218+
continue
219+
220+
# convert to json so we don't have to guess the data type
221+
location_requires = json.dumps(location["requires"])
222+
223+
DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, location_requires)
224+
# Second, check region requires for the presence of item name
225+
for region_name in DataValidation.region_table:
226+
region = DataValidation.region_table[region_name]
227+
228+
if "requires" not in region:
229+
continue
230+
231+
# convert to json so we don't have to guess the data type
232+
region_requires = json.dumps(region["requires"])
233+
234+
DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, region_requires)
235+
# then if something is requested, we loop items
236+
if values_requested:
237+
238+
# get all the available values with total count
239+
for item in DataValidation.item_table:
240+
# if the item is already progression, no need to check
241+
if not item.get("progression") and not item.get("progression_skip_balancing"):
242+
continue
243+
244+
item_count = item.get('count', None)
245+
if item_count is None: #check with none because 0 == false
246+
item_count = '1'
247+
248+
for key, count in item.get("value", {}).items():
249+
if not values_available.get(key.lower().strip()):
250+
values_available[key] = 0
251+
values_available[key] += int(count) * int(item_count)
252+
253+
# compare whats available vs requested
254+
errors = []
255+
for value, count in values_requested.items():
256+
if values_available.get(value, 0) < count:
257+
errors.append(f" '{value}': {values_available.get(value, 0)} out of the {count} {value} worth of progression items required can be found.")
258+
if errors:
259+
raise ValidationError("There are not enough progression items for the following values: \n" + "\n".join(errors))
260+
261+
@staticmethod
262+
def preFillCheckIfEnoughItemsForValue(world: World, multiworld: MultiWorld):
263+
from .Helpers import get_items_with_value, get_items_for_player
264+
player = world.player
265+
values_requested = {}
266+
267+
for region in multiworld.regions:
268+
if region.player != player:
269+
continue
270+
271+
manualregion = DataValidation.region_table.get(region.name, {})
272+
if "requires" in manualregion and manualregion["requires"]:
273+
region_requires = json.dumps(manualregion["requires"])
274+
275+
DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, region_requires)
276+
277+
for location in region.locations:
278+
manualLocation = world.location_name_to_location.get(location.name, {})
279+
if "requires" in manualLocation and manualLocation["requires"]:
280+
DataValidation._checkLocationRequiresForItemValueWithRegex(values_requested, manualLocation["requires"])
281+
282+
# compare whats available vs requested but only if there's anything requested
283+
if values_requested:
284+
errors = []
285+
existing_items = [item for item in get_items_for_player(multiworld, player) if item.code is not None and
286+
item.classification == ItemClassification.progression or item.classification == ItemClassification.progression_skip_balancing]
287+
288+
for value, val_count in values_requested.items():
289+
items_value = get_items_with_value(world, multiworld, value, player, True)
290+
found_count = 0
291+
if items_value:
292+
for item in existing_items:
293+
if item.name in items_value:
294+
found_count += items_value[item.name]
295+
296+
if found_count < val_count:
297+
errors.append(f" '{value}': {found_count} out of the {val_count} {value} worth of progression items required can be found.")
298+
if errors:
299+
raise ValidationError("There are not enough progression items for the following value(s): \n" + "\n".join(errors))
300+
195301
@staticmethod
196302
def checkRegionsConnectingToOtherRegions():
197303
for region_name in DataValidation.region_table:
@@ -270,7 +376,10 @@ def checkStartingItemsForBadSyntax():
270376
@staticmethod
271377
def checkPlacedItemsAndCategoriesForBadSyntax():
272378
for location in DataValidation.location_table:
273-
if not (place_item := location.get("place_item", False)) and not (place_item_category := location.get("place_item_category", False)):
379+
place_item = location.get("place_item", False)
380+
place_item_category = location.get("place_item_category", False)
381+
382+
if not place_item and not place_item_category:
274383
continue
275384

276385
if place_item and type(place_item) is not list:
@@ -338,7 +447,16 @@ def checkForNonStartingRegionsThatAreUnreachable():
338447
raise ValidationError("The region '%s' is set as a non-starting region, but has no regions that connect to it. It will be inaccessible." % nonstarter)
339448

340449

450+
def runPreFillDataValidation(world: World, multiworld: MultiWorld):
451+
validation_errors = []
452+
453+
# check if there is enough items with values
454+
try: DataValidation.preFillCheckIfEnoughItemsForValue(world, multiworld)
455+
except ValidationError as e: validation_errors.append(e)
341456

457+
if validation_errors:
458+
newline = "\n"
459+
raise Exception(f"\nValidationError(s) for pre_fill of player {world.player}: \n\n{newline.join([' - ' + str(validation_error) for validation_error in validation_errors])}\n\n")
342460
# Called during stage_assert_generate
343461
def runGenerationDataValidation() -> None:
344462
validation_errors = []
@@ -358,6 +476,10 @@ def runGenerationDataValidation() -> None:
358476
try: DataValidation.checkItemsThatShouldBeRequired()
359477
except ValidationError as e: validation_errors.append(e)
360478

479+
# check if there's enough Items with values to get to every location requesting it
480+
try: DataValidation.checkIfEnoughItemsForValue()
481+
except ValidationError as e: validation_errors.append(e)
482+
361483
# check that regions that are connected to are correct
362484
try: DataValidation.checkRegionsConnectingToOtherRegions()
363485
except ValidationError as e: validation_errors.append(e)

src/Helpers.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from BaseClasses import MultiWorld
1+
from BaseClasses import MultiWorld, Item
2+
from typing import Optional, List
3+
from worlds.AutoWorld import World
24
from .Data import category_table
35
from .Items import ManualItem
46
from .Locations import ManualLocation
@@ -85,4 +87,36 @@ def _is_manualobject_enabled(multiworld: MultiWorld, player: int, object: any) -
8587
enabled = False
8688
break
8789

88-
return enabled
90+
return enabled
91+
92+
def get_items_for_player(multiworld: MultiWorld, player: int) -> List[Item]:
93+
"""Return list of items of a player including placed items"""
94+
return [i for i in multiworld.get_items() if i.player == player]
95+
96+
def get_items_with_value(world: World, multiworld: MultiWorld, value: str, player: Optional[int] = None, force: bool = False) -> dict[str, int]:
97+
"""Return a dict of every items with a specific value type present in their respective 'value' dict\n
98+
Output in the format 'Item Name': 'value count'\n
99+
Keep a cache of the result and wont redo unless 'force == True'
100+
"""
101+
if player is None:
102+
player = world.player
103+
104+
player_items = get_items_for_player(multiworld, player)
105+
# Just a small check to prevent caching {} if items don't exist yet
106+
if not player_items:
107+
return {value: -1}
108+
109+
value = value.lower().strip()
110+
111+
if not hasattr(world, 'item_values'): #Cache of just the item values
112+
world.item_values = {}
113+
114+
if not world.item_values.get(player):
115+
world.item_values[player] = {}
116+
117+
if value not in world.item_values.get(player, {}).keys() or force:
118+
item_with_values = {i.name: world.item_name_to_item[i.name]['value'].get(value, 0)
119+
for i in player_items if i.code is not None
120+
and i.name in world.item_name_groups.get(f'has_{value}_value', [])}
121+
world.item_values[player][value] = item_with_values
122+
return world.item_values[player].get(value)

src/Items.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
item_name_groups[c] = []
4343
item_name_groups[c].append(item_name)
4444

45+
for v in item.get("value", {}).keys():
46+
group_name = f"has_{v.lower().strip()}_value"
47+
if group_name not in item_name_groups:
48+
item_name_groups[group_name] = []
49+
item_name_groups[group_name].append(item_name)
50+
4551
item_id_to_name[None] = "__Victory__"
4652
item_name_to_id = {name: id for id, name in item_id_to_name.items()}
4753

src/ManualClient.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
2+
import time
23
from typing import Any
4+
import typing
35
from worlds import AutoWorldRegister, network_data_package
46
import json
57

@@ -45,6 +47,10 @@ class ManualContext(SuperContext):
4547
tracker_reachable_locations = []
4648
tracker_reachable_events = []
4749

50+
set_deathlink = False
51+
last_death_link = 0
52+
deathlink_out = False
53+
4854
def __init__(self, server_address, password, game, player_name) -> None:
4955
super(ManualContext, self).__init__(server_address, password)
5056

@@ -55,7 +61,6 @@ def __init__(self, server_address, password, game, player_name) -> None:
5561

5662
self.send_index: int = 0
5763
self.syncing = False
58-
self.awaiting_bridge = False
5964
self.game = game
6065
self.username = player_name
6166

@@ -146,6 +151,10 @@ def on_package(self, cmd: str, args: dict):
146151
goal = args["slot_data"].get("goal")
147152
if goal and goal < len(self.victory_names):
148153
self.goal_location = self.get_location_by_name(self.victory_names[goal])
154+
if args['slot_data'].get('death_link'):
155+
self.ui.enable_death_link()
156+
self.set_deathlink = True
157+
self.last_death_link = 0
149158
logger.info(f"Slot data: {args['slot_data']}")
150159

151160
self.ui.build_tracker_and_locations_table()
@@ -155,6 +164,12 @@ def on_package(self, cmd: str, args: dict):
155164
elif cmd in {"RoomUpdate"}:
156165
self.ui.update_tracker_and_locations_table(update_highlights=False)
157166

167+
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
168+
super().on_deathlink(data)
169+
self.ui.death_link_button.text = f"Death Link: {data['source']}"
170+
self.ui.death_link_button.background_color = [1, 0, 0, 1]
171+
172+
158173
def on_tracker_updated(self, reachable_locations: list[str]):
159174
self.tracker_reachable_locations = reachable_locations
160175
self.ui.update_tracker_and_locations_table(update_highlights=True)
@@ -267,6 +282,23 @@ def set_active_location_accordion(self, instance):
267282

268283
index += 1
269284

285+
def enable_death_link(self):
286+
if not hasattr(self, "death_link_button"):
287+
self.death_link_button = Button(text="Death Link: Primed",
288+
size_hint_x=None, width=150)
289+
self.connect_layout.add_widget(self.death_link_button)
290+
self.death_link_button.bind(on_press=self.send_death_link)
291+
292+
def send_death_link(self, *args):
293+
if self.ctx.last_death_link:
294+
self.ctx.last_death_link = 0
295+
self.death_link_button.text = "Death Link: Primed"
296+
self.death_link_button.background_color = [1, 1, 1, 1]
297+
else:
298+
self.ctx.deathlink_out = True
299+
self.death_link_button.text = "Death Link: Sent"
300+
self.death_link_button.background_color = [0, 1, 0, 1]
301+
270302
def update_hints(self):
271303
super().update_hints()
272304
rebuild = False
@@ -600,6 +632,15 @@ async def game_watcher_manual(ctx: ManualContext):
600632
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
601633
await ctx.send_msgs(sync_msg)
602634
ctx.syncing = False
635+
636+
if ctx.set_deathlink:
637+
ctx.set_deathlink = False
638+
await ctx.update_death_link(True)
639+
640+
if ctx.deathlink_out:
641+
ctx.deathlink_out = False
642+
await ctx.send_death()
643+
603644
sending = []
604645
victory = ("__Victory__" in ctx.items_received)
605646
ctx.locations_checked = sending

src/Options.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from Options import FreeText, NumericOption, Toggle, DefaultOnToggle, Choice, TextChoice, Range, NamedRange, PerGameCommonOptions
1+
from Options import FreeText, NumericOption, Toggle, DefaultOnToggle, Choice, TextChoice, Range, NamedRange, PerGameCommonOptions, DeathLink
22
from dataclasses import make_dataclass
33
from .hooks.Options import before_options_defined, after_options_defined
4-
from .Data import category_table
4+
from .Data import category_table, game_table
55
from .Locations import victory_names
66
from .Items import item_table
77

@@ -20,6 +20,9 @@ class FillerTrapPercent(Range):
2020
if any(item.get('trap') for item in item_table):
2121
manual_options["filler_traps"] = FillerTrapPercent
2222

23+
if game_table.get("death_link"):
24+
manual_options["death_link"] = DeathLink
25+
2326
for category in category_table:
2427
for option_name in category_table[category].get("yaml_option", []):
2528
if option_name[0] == "!":

0 commit comments

Comments
 (0)