Core: Introduce 'Hint Priority' concept (#3506)

* Introduce 'Hint Priority' concept

* fix error when sorting hints while not connected

* fix 'found' -> 'status' kivy stuff

* remove extraneous warning

this warning fired if you clicked to select or toggle priority of any hint, as you weren't clicking on the header...

* skip scanning individual header widgets when not clicking on the header

* update hints on disconnection

* minor cleanup

* minor fixes/cleanup

* fix: hints not updating properly for receiving player

* update re: review

* 'type() is' -> 'isinstance()'

* cleanup, re: Jouramie's review

* Change 'priority' to 'status', add 'Unspecified' and 'Avoid' statuses, update colors

* cleanup

* move dicts out of functions

* fix: new hints being returned when hint already exists

* fix: show `Found` properly when hinting already-found hints

* import `Hint` and `HintStatus` directly from `NetUtils`

* Default any hinted `Trap` item to be classified as `Avoid` by default

* add some sanity checks

* re: Vi's feedback

* move dict out of function

* Update kvui.py

* remove unneeded dismiss message

* allow lclick to drop hint status dropdown

* underline hint statuses to indicate clickability

* only underline clickable statuses

* Update kvui.py

* Update kvui.py

---------

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
This commit is contained in:
Emily 2024-11-28 20:10:31 -05:00 committed by GitHub
parent b972e8c071
commit b783eab1e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 288 additions and 72 deletions

View File

@ -23,7 +23,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot, from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
@ -412,6 +412,7 @@ class CommonContext:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """ """ `msgs` JSON serializable """
@ -552,6 +553,13 @@ class CommonContext:
if self.input_task: if self.input_task:
self.input_task.cancel() self.input_task.cancel()
# Hints
def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
if status is not None:
msg["status"] = status
async_start(self.send_msgs([msg]), name="update_hint")
# DataPackage # DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str], async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int], remote_date_package_versions: typing.Dict[str, int],

View File

@ -276,7 +276,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
def precollect_hint(location): def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "") entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address, hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags) location.item.code, False, entrance, location.item.flags, False)
precollected_hints[location.player].add(hint) precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups: if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint) precollected_hints[location.item.player].add(hint)

View File

@ -41,7 +41,8 @@ import NetUtils
import Utils import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification
min_client_version = Version(0, 1, 6) min_client_version = Version(0, 1, 6)
colorama.init() colorama.init()
@ -228,7 +229,7 @@ class Context:
self.hint_cost = hint_cost self.hint_cost = hint_cost
self.location_check_points = location_check_points self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int) self.hints_used = collections.defaultdict(int)
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set) self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
self.release_mode: str = release_mode self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode self.collect_mode: str = collect_mode
@ -656,13 +657,29 @@ class Context:
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot]))) return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0 return 0
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None): def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None,
changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
"""Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
pair that has at least one hint modified will be added to the set.
"""
for hint_team, hint_slot in self.hints: for hint_team, hint_slot in self.hints:
if (team is None or team == hint_team) and (slot is None or slot == hint_slot): if team != hint_team and team is not None:
self.hints[hint_team, hint_slot] = { continue # Check specified team only, all if team is None
hint.re_check(self, hint_team) for hint in if slot != hint_slot and slot is not None:
self.hints[hint_team, hint_slot] continue # Check specified slot only, all if slot is None
} new_hints: typing.Set[Hint] = set()
for hint in self.hints[hint_team, hint_slot]:
new_hint = hint.re_check(self, hint_team)
new_hints.add(new_hint)
if hint == new_hint:
continue
for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
if changed is not None:
changed.add((hint_team,player))
if slot is not None and slot != player:
self.replace_hint(hint_team, player, hint, new_hint)
self.hints[hint_team, hint_slot] = new_hints
def get_rechecked_hints(self, team: int, slot: int): def get_rechecked_hints(self, team: int, slot: int):
self.recheck_hints(team, slot) self.recheck_hints(team, slot)
@ -711,7 +728,7 @@ class Context:
else: else:
return self.player_names[team, slot] return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False, def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
recipients: typing.Sequence[int] = None): recipients: typing.Sequence[int] = None):
"""Send and remember hints.""" """Send and remember hints."""
if only_new: if only_new:
@ -749,6 +766,17 @@ class Context:
for client in clients: for client in clients:
async_start(self.send_msgs(client, client_hints)) async_start(self.send_msgs(client, client_hints))
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
for hint in self.hints[team, finding_player]:
if hint.location == seeked_location:
return hint
return None
def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
if old_hint in self.hints[team, slot]:
self.hints[team, slot].remove(old_hint)
self.hints[team, slot].add(new_hint)
# "events" # "events"
def on_goal_achieved(self, client: Client): def on_goal_achieved(self, client: Client):
@ -1050,14 +1078,15 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot), "hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only "checked_locations": new_locations, # send back new checks only
}]) }])
old_hints = ctx.hints[team, slot].copy() updated_slots: typing.Set[tuple[int, int]] = set()
ctx.recheck_hints(team, slot) ctx.recheck_hints(team, slot, updated_slots)
if old_hints != ctx.hints[team, slot]: for hint_team, hint_slot in updated_slots:
ctx.on_changed_hints(team, slot) ctx.on_changed_hints(hint_team, hint_slot)
ctx.save() ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]: def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
-> typing.List[Hint]:
hints = [] hints = []
slots: typing.Set[int] = {slot} slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items(): for group_id, group in ctx.groups.items():
@ -1067,31 +1096,58 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
for finding_player, location_id, item_id, receiving_player, item_flags \ for finding_player, location_id, item_id, receiving_player, item_flags \
in ctx.locations.find_item(slots, seeked_item_id): in ctx.locations.find_item(slots, seeked_item_id):
found = location_id in ctx.location_checks[team, finding_player] prev_hint = ctx.get_hint(team, slot, location_id)
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") if prev_hint:
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance, hints.append(prev_hint)
item_flags)) else:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
return hints return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]: def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
-> typing.List[Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location] seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location) return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]: def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
-> typing.List[Hint]:
prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint:
return [prev_hint]
result = ctx.locations[slot].get(seeked_location, (None, None, None)) result = ctx.locations[slot].get(seeked_location, (None, None, None))
if any(result): if any(result):
item_id, receiving_player, item_flags = result item_id, receiving_player, item_flags = result
found = seeked_location in ctx.location_checks[team, slot] found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "") entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)] new_status = auto_status
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
return [] return []
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "(found)",
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
HintStatus.HINT_NO_PRIORITY: "(no priority)",
HintStatus.HINT_AVOID: "(avoid)",
HintStatus.HINT_PRIORITY: "(priority)",
}
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \ f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \ f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
@ -1099,7 +1155,8 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
if hint.entrance: if hint.entrance:
text += f" at {hint.entrance}" text += f" at {hint.entrance}"
return text + (". (found)" if hint.found else ".")
return text + ". " + status_names.get(hint.status, "(unknown)")
def json_format_send_event(net_item: NetworkItem, receiving_player: int): def json_format_send_event(net_item: NetworkItem, receiving_player: int):
@ -1503,7 +1560,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client) points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot) cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text: if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot]}
@ -1529,9 +1586,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = [] hints = []
elif not for_location: elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else: else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id) hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else: else:
game = self.ctx.games[self.client.slot] game = self.ctx.games[self.client.slot]
@ -1551,16 +1608,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]: for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name)) hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
elif hint_name in self.ctx.location_name_groups[game]: # location group name elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = [] hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]: for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game): if loc_name in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)) hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
else: # location name else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
else: else:
self.output(response) self.output(response)
@ -1832,13 +1889,51 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
target_item, target_player, flags = ctx.locations[client.slot][location] target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint: if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location)) hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags)) locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
if locs and create_as_hint: if locs and create_as_hint:
ctx.save() ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'UpdateHint':
location = args["location"]
player = args["player"]
status = args["status"]
if not isinstance(player, int) or not isinstance(location, int) \
or (status is not None and not isinstance(status, int)):
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
"original_cmd": cmd}])
return
hint = ctx.get_hint(client.team, player, location)
if not hint:
return # Ignored safely
if hint.receiving_player != client.slot:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
"original_cmd": cmd}])
return
new_hint = hint
if status is None:
return
try:
status = HintStatus(status)
except ValueError:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
return
new_hint = new_hint.re_prioritize(ctx, status)
if hint == new_hint:
return
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
ctx.save()
ctx.on_changed_hints(client.team, hint.finding_player)
ctx.on_changed_hints(client.team, hint.receiving_player)
elif cmd == 'StatusUpdate': elif cmd == 'StatusUpdate':
update_client_status(ctx, client, args["status"]) update_client_status(ctx, client, args["status"])
@ -2143,9 +2238,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = [] hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]: for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group)) hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
else: # item name or id else: # item name or id
hints = collect_hints(self.ctx, team, slot, item) hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
if hints: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
@ -2179,14 +2274,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable: if usable:
if isinstance(location, int): if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location) hints = collect_hint_location_id(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]: elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = [] hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]: for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game): if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group)) hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
HintStatus.HINT_UNSPECIFIED))
else: else:
hints = collect_hint_location_name(self.ctx, team, slot, location) hints = collect_hint_location_name(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
if hints: if hints:
self.ctx.notify_hints(team, hints) self.ctx.notify_hints(team, hints)
else: else:

View File

@ -29,6 +29,14 @@ class ClientStatus(ByValue, enum.IntEnum):
CLIENT_GOAL = 30 CLIENT_GOAL = 30
class HintStatus(enum.IntEnum):
HINT_FOUND = 0
HINT_UNSPECIFIED = 1
HINT_NO_PRIORITY = 10
HINT_AVOID = 20
HINT_PRIORITY = 30
class SlotType(ByValue, enum.IntFlag): class SlotType(ByValue, enum.IntFlag):
spectator = 0b00 spectator = 0b00
player = 0b01 player = 0b01
@ -297,6 +305,20 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs}) parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "(found)",
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
HintStatus.HINT_NO_PRIORITY: "(no priority)",
HintStatus.HINT_AVOID: "(avoid)",
HintStatus.HINT_PRIORITY: "(priority)",
}
status_colors: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "green",
HintStatus.HINT_UNSPECIFIED: "white",
HintStatus.HINT_NO_PRIORITY: "slateblue",
HintStatus.HINT_AVOID: "salmon",
HintStatus.HINT_PRIORITY: "plum",
}
class Hint(typing.NamedTuple): class Hint(typing.NamedTuple):
receiving_player: int receiving_player: int
finding_player: int finding_player: int
@ -305,14 +327,21 @@ class Hint(typing.NamedTuple):
found: bool found: bool
entrance: str = "" entrance: str = ""
item_flags: int = 0 item_flags: int = 0
status: HintStatus = HintStatus.HINT_UNSPECIFIED
def re_check(self, ctx, team) -> Hint: def re_check(self, ctx, team) -> Hint:
if self.found: if self.found and self.status == HintStatus.HINT_FOUND:
return self return self
found = self.location in ctx.location_checks[team, self.finding_player] found = self.location in ctx.location_checks[team, self.finding_player]
if found: if found:
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance, return self._replace(found=found, status=HintStatus.HINT_FOUND)
self.item_flags) return self
def re_prioritize(self, ctx, status: HintStatus) -> Hint:
if self.found and status != HintStatus.HINT_FOUND:
status = HintStatus.HINT_FOUND
if status != self.status:
return self._replace(status=status)
return self return self
def __hash__(self): def __hash__(self):
@ -334,10 +363,8 @@ class Hint(typing.NamedTuple):
else: else:
add_json_text(parts, "'s World") add_json_text(parts, "'s World")
add_json_text(parts, ". ") add_json_text(parts, ". ")
if self.found: add_json_text(parts, status_names.get(self.status, "(unknown)"), type="color",
add_json_text(parts, "(found)", type="color", color="green") color=status_colors.get(self.status, "red"))
else:
add_json_text(parts, "(not found)", type="color", color="red")
return {"cmd": "PrintJSON", "data": parts, "type": "Hint", return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player, "receiving": self.receiving_player,

View File

@ -421,7 +421,8 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) return getattr(builtins, name)
# used by MultiServer -> savegame/multidata # used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}: if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
"SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name) return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem": if module == "worlds.generic" and name == "PlandoItem":

View File

@ -59,7 +59,7 @@
finding_text: "Finding Player" finding_text: "Finding Player"
location_text: "Location" location_text: "Location"
entrance_text: "Entrance" entrance_text: "Entrance"
found_text: "Found?" status_text: "Status"
TooltipLabel: TooltipLabel:
id: receiving id: receiving
sort_key: 'receiving' sort_key: 'receiving'
@ -96,9 +96,9 @@
valign: 'center' valign: 'center'
pos_hint: {"center_y": 0.5} pos_hint: {"center_y": 0.5}
TooltipLabel: TooltipLabel:
id: found id: status
sort_key: 'found' sort_key: 'status'
text: root.found_text text: root.status_text
halign: 'center' halign: 'center'
valign: 'center' valign: 'center'
pos_hint: {"center_y": 0.5} pos_hint: {"center_y": 0.5}

View File

@ -272,6 +272,7 @@ These packets are sent purely from client to server. They are not accepted by cl
* [Sync](#Sync) * [Sync](#Sync)
* [LocationChecks](#LocationChecks) * [LocationChecks](#LocationChecks)
* [LocationScouts](#LocationScouts) * [LocationScouts](#LocationScouts)
* [UpdateHint](#UpdateHint)
* [StatusUpdate](#StatusUpdate) * [StatusUpdate](#StatusUpdate)
* [Say](#Say) * [Say](#Say)
* [GetDataPackage](#GetDataPackage) * [GetDataPackage](#GetDataPackage)
@ -342,6 +343,29 @@ This is useful in cases where an item appears in the game world, such as 'ledge
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. | | locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. | | create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
### UpdateHint
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| player | int | The ID of the player whose location is being hinted for. |
| location | int | The ID of the location to update the hint for. If no hint exists for this location, the packet is ignored. |
| status | [HintStatus](#HintStatus) | Optional. If included, sets the status of the hint to this status. |
#### HintStatus
An enumeration containing the possible hint states.
```python
import enum
class HintStatus(enum.IntEnum):
HINT_FOUND = 0
HINT_UNSPECIFIED = 1
HINT_NO_PRIORITY = 10
HINT_AVOID = 20
HINT_PRIORITY = 30
```
### StatusUpdate ### StatusUpdate
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past) Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)

96
kvui.py
View File

@ -52,6 +52,7 @@ from kivy.uix.boxlayout import BoxLayout
from kivy.uix.floatlayout import FloatLayout from kivy.uix.floatlayout import FloatLayout
from kivy.uix.label import Label from kivy.uix.label import Label
from kivy.uix.progressbar import ProgressBar from kivy.uix.progressbar import ProgressBar
from kivy.uix.dropdown import DropDown
from kivy.utils import escape_markup from kivy.utils import escape_markup
from kivy.lang import Builder from kivy.lang import Builder
from kivy.uix.recycleview.views import RecycleDataViewBehavior from kivy.uix.recycleview.views import RecycleDataViewBehavior
@ -63,7 +64,7 @@ from kivy.uix.popup import Popup
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25) fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType, HintStatus
from Utils import async_start, get_input_text_from_response from Utils import async_start, get_input_text_from_response
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@ -300,11 +301,11 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
""" Respond to the selection of items in the view. """ """ Respond to the selection of items in the view. """
self.selected = is_selected self.selected = is_selected
class HintLabel(RecycleDataViewBehavior, BoxLayout): class HintLabel(RecycleDataViewBehavior, BoxLayout):
selected = BooleanProperty(False) selected = BooleanProperty(False)
striped = BooleanProperty(False) striped = BooleanProperty(False)
index = None index = None
dropdown: DropDown
def __init__(self): def __init__(self):
super(HintLabel, self).__init__() super(HintLabel, self).__init__()
@ -313,10 +314,32 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.finding_text = "" self.finding_text = ""
self.location_text = "" self.location_text = ""
self.entrance_text = "" self.entrance_text = ""
self.found_text = "" self.status_text = ""
self.hint = {}
for child in self.children: for child in self.children:
child.bind(texture_size=self.set_height) child.bind(texture_size=self.set_height)
ctx = App.get_running_app().ctx
self.dropdown = DropDown()
def set_value(button):
self.dropdown.select(button.status)
def select(instance, data):
ctx.update_hint(self.hint["location"],
self.hint["finding_player"],
data)
for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID):
name = status_names[status]
status_button = Button(text=name, size_hint_y=None, height=dp(50))
status_button.status = status
status_button.bind(on_release=set_value)
self.dropdown.add_widget(status_button)
self.dropdown.bind(on_select=select)
def set_height(self, instance, value): def set_height(self, instance, value):
self.height = max([child.texture_size[1] for child in self.children]) self.height = max([child.texture_size[1] for child in self.children])
@ -328,7 +351,8 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.finding_text = data["finding"]["text"] self.finding_text = data["finding"]["text"]
self.location_text = data["location"]["text"] self.location_text = data["location"]["text"]
self.entrance_text = data["entrance"]["text"] self.entrance_text = data["entrance"]["text"]
self.found_text = data["found"]["text"] self.status_text = data["status"]["text"]
self.hint = data["status"]["hint"]
self.height = self.minimum_height self.height = self.minimum_height
return super(HintLabel, self).refresh_view_attrs(rv, index, data) return super(HintLabel, self).refresh_view_attrs(rv, index, data)
@ -338,13 +362,21 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
return True return True
if self.index: # skip header if self.index: # skip header
if self.collide_point(*touch.pos): if self.collide_point(*touch.pos):
if self.selected: status_label = self.ids["status"]
if status_label.collide_point(*touch.pos):
if self.hint["status"] == HintStatus.HINT_FOUND:
return
ctx = App.get_running_app().ctx
if ctx.slot == self.hint["receiving_player"]: # If this player owns this hint
# open a dropdown
self.dropdown.open(self.ids["status"])
elif self.selected:
self.parent.clear_selection() self.parent.clear_selection()
else: else:
text = "".join((self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ", text = "".join((self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
self.finding_text, "\'s World", (" at " + self.entrance_text) self.finding_text, "\'s World", (" at " + self.entrance_text)
if self.entrance_text != "Vanilla" if self.entrance_text != "Vanilla"
else "", ". (", self.found_text.lower(), ")")) else "", ". (", self.status_text.lower(), ")"))
temp = MarkupLabel(text).markup temp = MarkupLabel(text).markup
text = "".join( text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]"))) part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
@ -358,18 +390,16 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
for child in self.children: for child in self.children:
if child.collide_point(*touch.pos): if child.collide_point(*touch.pos):
key = child.sort_key key = child.sort_key
parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower() if key == "status":
parent.hint_sorter = lambda element: element["status"]["hint"]["status"]
else: parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
if key == parent.sort_key: if key == parent.sort_key:
# second click reverses order # second click reverses order
parent.reversed = not parent.reversed parent.reversed = not parent.reversed
else: else:
parent.sort_key = key parent.sort_key = key
parent.reversed = False parent.reversed = False
break App.get_running_app().update_hints()
else:
logging.warning("Did not find clicked header for sorting.")
App.get_running_app().update_hints()
def apply_selection(self, rv, index, is_selected): def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """ """ Respond to the selection of items in the view. """
@ -663,7 +693,7 @@ class GameManager(App):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J" self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
def update_hints(self): def update_hints(self):
hints = self.ctx.stored_data[f"_read_hints_{self.ctx.team}_{self.ctx.slot}"] hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
self.log_panels["Hints"].refresh_hints(hints) self.log_panels["Hints"].refresh_hints(hints)
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed # default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
@ -719,6 +749,22 @@ class UILog(RecycleView):
element.height = element.texture_size[1] element.height = element.texture_size[1]
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "Found",
HintStatus.HINT_UNSPECIFIED: "Unspecified",
HintStatus.HINT_NO_PRIORITY: "No Priority",
HintStatus.HINT_AVOID: "Avoid",
HintStatus.HINT_PRIORITY: "Priority",
}
status_colors: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "green",
HintStatus.HINT_UNSPECIFIED: "white",
HintStatus.HINT_NO_PRIORITY: "cyan",
HintStatus.HINT_AVOID: "salmon",
HintStatus.HINT_PRIORITY: "plum",
}
class HintLog(RecycleView): class HintLog(RecycleView):
header = { header = {
"receiving": {"text": "[u]Receiving Player[/u]"}, "receiving": {"text": "[u]Receiving Player[/u]"},
@ -726,12 +772,13 @@ class HintLog(RecycleView):
"finding": {"text": "[u]Finding Player[/u]"}, "finding": {"text": "[u]Finding Player[/u]"},
"location": {"text": "[u]Location[/u]"}, "location": {"text": "[u]Location[/u]"},
"entrance": {"text": "[u]Entrance[/u]"}, "entrance": {"text": "[u]Entrance[/u]"},
"found": {"text": "[u]Status[/u]"}, "status": {"text": "[u]Status[/u]",
"hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}},
"striped": True, "striped": True,
} }
sort_key: str = "" sort_key: str = ""
reversed: bool = False reversed: bool = True
def __init__(self, parser): def __init__(self, parser):
super(HintLog, self).__init__() super(HintLog, self).__init__()
@ -739,8 +786,18 @@ class HintLog(RecycleView):
self.parser = parser self.parser = parser
def refresh_hints(self, hints): def refresh_hints(self, hints):
if not hints: # Fix the scrolling looking visually wrong in some edge cases
self.scroll_y = 1.0
data = [] data = []
ctx = App.get_running_app().ctx
for hint in hints: for hint in hints:
if not hint.get("status"): # Allows connecting to old servers
hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED
hint_status_node = self.parser.handle_node({"type": "color",
"color": status_colors.get(hint["status"], "red"),
"text": status_names.get(hint["status"], "Unknown")})
if hint["status"] != HintStatus.HINT_FOUND and hint["receiving_player"] == ctx.slot:
hint_status_node = f"[u]{hint_status_node}[/u]"
data.append({ data.append({
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})}, "receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node({ "item": {"text": self.parser.handle_node({
@ -758,9 +815,10 @@ class HintLog(RecycleView):
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text", "entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
"color": "blue", "text": hint["entrance"] "color": "blue", "text": hint["entrance"]
if hint["entrance"] else "Vanilla"})}, if hint["entrance"] else "Vanilla"})},
"found": { "status": {
"text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red", "text": hint_status_node,
"text": "Found" if hint["found"] else "Not Found"})}, "hint": hint,
},
}) })
data.sort(key=self.hint_sorter, reverse=self.reversed) data.sort(key=self.hint_sorter, reverse=self.reversed)
@ -771,7 +829,7 @@ class HintLog(RecycleView):
@staticmethod @staticmethod
def hint_sorter(element: dict) -> str: def hint_sorter(element: dict) -> str:
return "" return element["status"]["hint"]["status"] # By status by default
def fix_heights(self): def fix_heights(self):
"""Workaround fix for divergent texture and layout heights""" """Workaround fix for divergent texture and layout heights"""