diff --git a/worlds/stardew_valley/__init__.py b/worlds/stardew_valley/__init__.py index e2d49e64ae14..cd8027fd9038 100644 --- a/worlds/stardew_valley/__init__.py +++ b/worlds/stardew_valley/__init__.py @@ -5,6 +5,7 @@ from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState from Options import PerGameCommonOptions from worlds.AutoWorld import World, WebWorld +from worlds.LauncherComponents import launch_subprocess, components, Component, icon_paths, Type from .bundles.bundle_room import BundleRoom from .bundles.bundles import get_all_bundles from .content import StardewContent, create_content @@ -29,6 +30,7 @@ UNIVERSAL_TRACKER_SEED_PROPERTY = "ut_seed" client_version = 0 +TRACKER_ENABLED = True class StardewLocation(Location): @@ -56,6 +58,27 @@ class StardewWebWorld(WebWorld): )] +if TRACKER_ENABLED: + from .. import user_folder + import os + + # Best effort to detect if universal tracker is installed + if any("tracker.apworld" in f.name for f in os.scandir(user_folder)): + def launch_client(): + from .client import launch + launch_subprocess(launch, name="Stardew Valley Tracker") + + + components.append(Component( + "Stardew Valley Tracker", + func=launch_client, + component_type=Type.CLIENT, + icon='stardew' + )) + + icon_paths['stardew'] = f"ap:{__name__}/stardew.png" + + class StardewValleyWorld(World): """ Stardew Valley is an open-ended country-life RPG. You can farm, fish, mine, fight, complete quests, diff --git a/worlds/stardew_valley/client.py b/worlds/stardew_valley/client.py new file mode 100644 index 000000000000..3a16f165c0e1 --- /dev/null +++ b/worlds/stardew_valley/client.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import asyncio +import re +# webserver imports +import urllib.parse + +import Utils +from BaseClasses import MultiWorld, CollectionState +from CommonClient import logger, get_base_parser, gui_enabled, server_loop +from MultiServer import mark_raw +from NetUtils import JSONMessagePart +from .logic.logic import StardewLogic +from .stardew_rule.rule_explain import explain, ExplainMode, RuleExplanation + +try: + from worlds.tracker.TrackerClient import TrackerGameContext, TrackerCommandProcessor as ClientCommandProcessor, UT_VERSION, updateTracker # noqa + + tracker_loaded = True +except ImportError: + from CommonClient import CommonContext, ClientCommandProcessor + + + class TrackerGameContextMixin: + """Expecting the TrackerGameContext to have these methods.""" + multiworld: MultiWorld + player_id: int + + def build_gui(self, manager): + ... + + def run_generator(self): + ... + + def load_kv(self): + ... + + + class TrackerGameContext(CommonContext, TrackerGameContextMixin): + pass + + + tracker_loaded = False + UT_VERSION = "Not found" + + +class StardewCommandProcessor(ClientCommandProcessor): + ctx: StardewClientContext + + @mark_raw + def _cmd_explain(self, location: str = ""): + """Explain the logic behind a location.""" + logic = self.ctx.get_logic() + if logic is None: + return + + try: + rule = logic.region.can_reach_location(location) + expl = explain(rule, get_updated_state(self.ctx), expected=None, mode=ExplainMode.CLIENT) + except KeyError: + + result, usable, response = Utils.get_intended_text(location, [loc.name for loc in self.ctx.multiworld.get_locations(1)]) + if usable: + rule = logic.region.can_reach_location(result) + expl = explain(rule, get_updated_state(self.ctx), expected=None, mode=ExplainMode.CLIENT) + else: + logger.warning(response) + return + + self.ctx.previous_explanation = expl + self.ctx.ui.print_json(parse_explanation(expl)) + + @mark_raw + def _cmd_explain_item(self, item: str = ""): + """Explain the logic behind a game item.""" + logic = self.ctx.get_logic() + if logic is None: + return + + result, usable, response = Utils.get_intended_text(item, logic.registry.item_rules.keys()) + if usable: + rule = logic.has(result) + expl = explain(rule, get_updated_state(self.ctx), expected=None, mode=ExplainMode.CLIENT) + else: + logger.warning(response) + return + + self.ctx.previous_explanation = expl + self.ctx.ui.print_json(parse_explanation(expl)) + + @mark_raw + def _cmd_explain_missing(self, location: str = ""): + """Explain what is missing for a location to be in logic. It explains the logic behind a location, while skipping the rules that are already satisfied.""" + self.__explain(location, expected=True) + + @mark_raw + def _cmd_explain_how(self, location: str = ""): + """Explain how a location is in logic. It explains the logic behind the location, while skipping the rules that are not satisfied.""" + self.__explain(location, expected=False) + + def __explain(self, location: str = "", expected: bool | None = None): + logic = self.ctx.get_logic() + if logic is None: + return + + try: + rule = logic.region.can_reach_location(location) + expl = explain(rule, get_updated_state(self.ctx), expected=expected, mode=ExplainMode.CLIENT) + except KeyError: + + result, usable, response = Utils.get_intended_text(location, [loc.name for loc in self.ctx.multiworld.get_locations(1)]) + if usable: + rule = logic.region.can_reach_location(result) + expl = explain(rule, get_updated_state(self.ctx), expected=expected, mode=ExplainMode.CLIENT) + else: + logger.warning(response) + return + + self.ctx.previous_explanation = expl + self.ctx.ui.print_json(parse_explanation(expl)) + + @mark_raw + def _cmd_more(self, index: str = ""): + """Will tell you what's missing to consider a location in logic.""" + if self.ctx.previous_explanation is None: + logger.warning("No previous explanation found.") + return + + try: + expl = self.ctx.previous_explanation.more(int(index)) + except (ValueError, IndexError): + logger.info("Which previous rule do you want to explained?") + for i, rule in enumerate(self.ctx.previous_explanation.more_explanations): + logger.info(f"/more {i} -> {str(rule)})") + return + + self.ctx.previous_explanation = expl + self.ctx.ui.print_json(parse_explanation(expl)) + + if not tracker_loaded: + del _cmd_explain + del _cmd_explain_missing + + +class StardewClientContext(TrackerGameContext): + game = "Stardew Valley" + command_processor = StardewCommandProcessor + previous_explanation: RuleExplanation | None = None + + def make_gui(self): + ui = super().make_gui() # before the kivy imports so kvui gets loaded first + + class StardewManager(ui): + base_title = f"Stardew Valley Tracker with UT {UT_VERSION} for AP version" # core appends ap version so this works + ctx: StardewClientContext + + def build(self): + container = super().build() + if not tracker_loaded: + logger.info("To enable the tracker page, install Universal Tracker.") + + return container + + return StardewManager + + def get_logic(self) -> StardewLogic | None: + if self.player_id is None: + logger.warning("Internal logic was not able to load, check your yamls and relaunch.") + return None + + if self.game != "Stardew Valley": + logger.warning(f"Please connect to a slot with explainable logic (not {self.game}).") + return None + + return self.multiworld.worlds[self.player_id].logic + + +def parse_explanation(explanation: RuleExplanation) -> list[JSONMessagePart]: + # Split the explanation in parts, by isolating all the delimiters, being \(, \), & , -> , | , \d+x , \[ , \] , \(\w+\), \n\s* + result_regex = r"(\(|\)| & | -> | \| |\d+x | \[|\](?: ->)?\s*| \(\w+\)|\n\s*)" + splits = re.split(result_regex, str(explanation).strip()) + + messages = [] + for s in splits: + if len(s) == 0: + continue + + if s == "True": + messages.append({"type": "color", "color": "green", "text": s}) + elif s == "False": + messages.append({"type": "color", "color": "salmon", "text": s}) + elif s.startswith("Reach Location "): + messages.append({"type": "text", "text": "Reach Location "}) + messages.append({"type": "location_name", "text": s[15:]}) + elif s.startswith("Reach Entrance "): + messages.append({"type": "text", "text": "Reach Entrance "}) + messages.append({"type": "entrance_name", "text": s[15:]}) + elif s.startswith("Reach Region "): + messages.append({"type": "text", "text": "Reach Region "}) + messages.append({"type": "color", "color": "yellow", "text": s[13:]}) + elif s.startswith("Received event "): + messages.append({"type": "text", "text": "Received event "}) + messages.append({"type": "item_name", "text": s[15:]}) + elif s.startswith("Received "): + messages.append({"type": "text", "text": "Received "}) + messages.append({"type": "item_name", "flags": 0b001, "text": s[9:]}) + elif s.startswith("Has "): + if s[4].isdigit(): + messages.append({"type": "text", "text": "Has "}) + digit_end = re.search(r"\D", s[4:]) + digit = s[4:4 + digit_end.start()] + messages.append({"type": "color", "color": "cyan", "text": digit}) + messages.append({"type": "text", "text": s[4 + digit_end.start():]}) + + else: + messages.append({"text": s, "type": "text"}) + else: + messages.append({"text": s, "type": "text"}) + + return messages + + +def get_updated_state(ctx: TrackerGameContext) -> CollectionState: + return updateTracker(ctx).state + + +async def main(args): + ctx = StardewClientContext(args.connect, args.password) + + ctx.auth = args.name + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + + if tracker_loaded: + ctx.run_generator() + else: + logger.warning("Could not find Universal Tracker.") + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + await ctx.exit_event.wait() + await ctx.shutdown() + + +def launch(*args): + parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") + parser.add_argument('--name', default=None, help="Slot Name to connect as.") + parser.add_argument("url", nargs="?", help="Archipelago connection url") + args = parser.parse_args(args) + + if args.url: + url = urllib.parse.urlparse(args.url) + args.connect = url.netloc + if url.username: + args.name = urllib.parse.unquote(url.username) + if url.password: + args.password = urllib.parse.unquote(url.password) + + asyncio.run(main(args)) diff --git a/worlds/stardew_valley/stardew.ico b/worlds/stardew_valley/stardew.ico new file mode 100644 index 000000000000..2a6c6a3beb60 Binary files /dev/null and b/worlds/stardew_valley/stardew.ico differ diff --git a/worlds/stardew_valley/stardew.png b/worlds/stardew_valley/stardew.png new file mode 100644 index 000000000000..8ec396836f05 Binary files /dev/null and b/worlds/stardew_valley/stardew.png differ diff --git a/worlds/stardew_valley/stardew_rule/base.py b/worlds/stardew_valley/stardew_rule/base.py index ff1fbba37648..a0a1c4f6aefa 100644 --- a/worlds/stardew_valley/stardew_rule/base.py +++ b/worlds/stardew_valley/stardew_rule/base.py @@ -432,8 +432,17 @@ def evaluate_while_simplifying(self, state: CollectionState) -> Tuple[StardewRul def rules_count(self): return len(self.rules) + def __str__(self): + if all(value == 1 for value in self.counter.values()): + return f"Has {self.count} of [{', '.join(str(rule) for rule in self.counter.keys())}]" + + return f"Has {self.count} of [{', '.join(f'{value}x {str(rule)}' for rule, value in self.counter.items())}]" + def __repr__(self): - return f"Received {self.count} [{', '.join(f'{value}x {repr(rule)}' for rule, value in self.counter.items())}]" + if all(value == 1 for value in self.counter.values()): + return f"Has {self.count} of [{', '.join(repr(rule) for rule in self.counter.keys())}]" + + return f"Has {self.count} of [{', '.join(f'{value}x {repr(rule)}' for rule, value in self.counter.items())}]" @dataclass(frozen=True) diff --git a/worlds/stardew_valley/stardew_rule/rule_explain.py b/worlds/stardew_valley/stardew_rule/rule_explain.py index 2e2b9c959d7f..8361c3c11483 100644 --- a/worlds/stardew_valley/stardew_rule/rule_explain.py +++ b/worlds/stardew_valley/stardew_rule/rule_explain.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum from dataclasses import dataclass, field from functools import cached_property, singledispatch from typing import Iterable, Set, Tuple, List, Optional @@ -9,12 +10,46 @@ from . import StardewRule, AggregatingStardewRule, Count, Has, TotalReceived, Received, Reach, true_ +class ExplainMode(enum.Enum): + VERBOSE = enum.auto() + CLIENT = enum.auto() + + +@dataclass +class MoreExplanation: + rule: StardewRule + state: CollectionState + more_index: int + mode: ExplainMode + + @cached_property + def result(self) -> bool: + try: + return self.rule(self.state) + except KeyError: + return False + + def summary(self, depth=0) -> str: + if self.mode is ExplainMode.CLIENT: + depth *= 2 + + line = " " * depth + f"{str(self.rule)} -> {self.result}" + line += f" [use `/more {self.more_index}` to explain]" + + return line + + def __str__(self, depth=0): + return self.summary(depth) + + @dataclass class RuleExplanation: rule: StardewRule state: CollectionState = field(repr=False, hash=False) - expected: bool + expected: bool | None + mode: ExplainMode sub_rules: Iterable[StardewRule] = field(default_factory=list) + more_explanations: List[StardewRule] = field(default_factory=list, repr=False, hash=False) explored_rules_key: Set[Tuple[str, str]] = field(default_factory=set, repr=False, hash=False) current_rule_explored: bool = False @@ -25,19 +60,25 @@ def __post_init__(self): self.sub_rules = [] def summary(self, depth=0) -> str: - summary = " " * depth + f"{str(self.rule)} -> {self.result}" + if self.mode is ExplainMode.CLIENT: + depth *= 2 + + line = " " * depth + f"{str(self.rule)} -> {self.result}" if self.current_rule_explored: - summary += " [Already explained]" - return summary + line += " [Already explained]" + + return line def __str__(self, depth=0): if not self.sub_rules: return self.summary(depth) - return self.summary(depth) + "\n" + "\n".join(i.__str__(depth + 1) - if i.result is not self.expected else i.summary(depth + 1) + return self.summary(depth) + "\n" + "\n".join(i.__str__(depth + 1) if self.expected is None or i.result is not self.expected else i.summary(depth + 1) for i in sorted(self.explained_sub_rules, key=lambda x: x.result)) + def more(self, more_index: int) -> RuleExplanation: + return explain(self.more_explanations[more_index], self.state, self.expected, self.mode) + @cached_property def result(self) -> bool: try: @@ -51,7 +92,21 @@ def explained_sub_rules(self) -> List[RuleExplanation]: if rule_key is not None: self.explored_rules_key.add(rule_key) - return [_explain(i, self.state, self.expected, self.explored_rules_key) for i in self.sub_rules] + if self.mode == ExplainMode.CLIENT: + sub_explanations = [] + for sub_rule in self.sub_rules: + if isinstance(sub_rule, Reach) and sub_rule.resolution_hint == 'Entrance': + sub_explanations.append(MoreExplanation(sub_rule, self.state, len(self.more_explanations), self.mode)) + self.more_explanations.append(sub_rule) + elif isinstance(sub_rule, Reach) and sub_rule.resolution_hint == 'Location': + sub_explanations.append(MoreExplanation(sub_rule, self.state, len(self.more_explanations), self.mode)) + self.more_explanations.append(sub_rule) + else: + sub_explanations.append(_explain(sub_rule, self.state, self.expected, self.mode, self.more_explanations, self.explored_rules_key)) + + return sub_explanations + + return [_explain(sub_rule, self.state, self.expected, self.mode, self.more_explanations, self.explored_rules_key) for sub_rule in self.sub_rules] @dataclass @@ -60,9 +115,13 @@ class CountSubRuleExplanation(RuleExplanation): @staticmethod def from_explanation(expl: RuleExplanation, count: int) -> CountSubRuleExplanation: - return CountSubRuleExplanation(expl.rule, expl.state, expl.expected, expl.sub_rules, expl.explored_rules_key, expl.current_rule_explored, count) + return CountSubRuleExplanation(expl.rule, expl.state, expl.expected, expl.mode, expl.sub_rules, more_explanations=expl.more_explanations, + explored_rules_key=expl.explored_rules_key, current_rule_explored=expl.current_rule_explored, count=count) def summary(self, depth=0) -> str: + if self.mode is ExplainMode.CLIENT: + depth *= 2 + summary = " " * depth + f"{self.count}x {str(self.rule)} -> {self.result}" if self.current_rule_explored: summary += " [Already explained]" @@ -75,49 +134,61 @@ class CountExplanation(RuleExplanation): @cached_property def explained_sub_rules(self) -> List[RuleExplanation]: + if all(value == 1 for value in self.rule.counter.values()): + return super().explained_sub_rules + return [ - CountSubRuleExplanation.from_explanation(_explain(rule, self.state, self.expected, self.explored_rules_key), count) + CountSubRuleExplanation.from_explanation(_explain(rule, self.state, self.expected, self.mode, self.more_explanations, self.explored_rules_key), + count) for rule, count in self.rule.counter.items() ] -def explain(rule: CollectionRule, state: CollectionState, expected: bool = True) -> RuleExplanation: +def explain(rule: CollectionRule, state: CollectionState, expected: bool | None = True, mode: ExplainMode = ExplainMode.VERBOSE) -> RuleExplanation: if isinstance(rule, StardewRule): - return _explain(rule, state, expected, explored_spots=set()) + return _explain(rule, state, expected, mode, more_explanations=list(), explored_spots=set()) else: return f"Value of rule {str(rule)} was not {str(expected)} in {str(state)}" # noqa @singledispatch -def _explain(rule: StardewRule, state: CollectionState, expected: bool, explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: - return RuleExplanation(rule, state, expected, explored_rules_key=explored_spots) +def _explain(rule: StardewRule, state: CollectionState, expected: bool | None, mode: ExplainMode, more_explanations: list[StardewRule], + explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: + return RuleExplanation(rule, state, expected, mode, more_explanations=more_explanations, explored_rules_key=explored_spots) @_explain.register -def _(rule: AggregatingStardewRule, state: CollectionState, expected: bool, explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: - return RuleExplanation(rule, state, expected, rule.original_rules, explored_rules_key=explored_spots) +def _(rule: AggregatingStardewRule, state: CollectionState, expected: bool | None, mode: ExplainMode, more_explanations: list[StardewRule], + explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: + return RuleExplanation(rule, state, expected, mode, rule.original_rules, more_explanations=more_explanations, explored_rules_key=explored_spots) @_explain.register -def _(rule: Count, state: CollectionState, expected: bool, explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: - return CountExplanation(rule, state, expected, rule.rules, explored_rules_key=explored_spots) +def _(rule: Count, state: CollectionState, expected: bool | None, mode: ExplainMode, more_explanations: list[StardewRule], + explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: + return CountExplanation(rule, state, expected, mode, rule.rules, more_explanations=more_explanations, explored_rules_key=explored_spots) @_explain.register -def _(rule: Has, state: CollectionState, expected: bool, explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: +def _(rule: Has, state: CollectionState, expected: bool | None, mode: ExplainMode, more_explanations: list[StardewRule], + explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: try: - return RuleExplanation(rule, state, expected, [rule.other_rules[rule.item]], explored_rules_key=explored_spots) + return RuleExplanation(rule, state, expected, mode, [rule.other_rules[rule.item]], more_explanations=more_explanations, + explored_rules_key=explored_spots) except KeyError: - return RuleExplanation(rule, state, expected, explored_rules_key=explored_spots) + return RuleExplanation(rule, state, expected, mode, more_explanations=more_explanations, explored_rules_key=explored_spots) @_explain.register -def _(rule: TotalReceived, state: CollectionState, expected: bool, explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: - return RuleExplanation(rule, state, expected, [Received(i, rule.player, 1) for i in rule.items], explored_rules_key=explored_spots) +def _(rule: TotalReceived, state: CollectionState, expected: bool | None, mode: ExplainMode, more_explanations: list[StardewRule], + explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: + return RuleExplanation(rule, state, expected, mode, [Received(i, rule.player, 1) for i in rule.items], more_explanations=more_explanations, + explored_rules_key=explored_spots) @_explain.register -def _(rule: Reach, state: CollectionState, expected: bool, explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: +def _(rule: Reach, state: CollectionState, expected: bool | None, mode: ExplainMode, more_explanations: list[StardewRule], + explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: access_rules = None if rule.resolution_hint == 'Location': spot = state.multiworld.get_location(rule.spot, rule.player) @@ -135,6 +206,10 @@ def _(rule: Reach, state: CollectionState, expected: bool, explored_spots: Set[T elif rule.resolution_hint == 'Entrance': spot = state.multiworld.get_entrance(rule.spot, rule.player) + if isinstance(spot.access_rule, StardewRule): + if spot.access_rule is not true_: + access_rules = [spot.access_rule] + if isinstance(spot.access_rule, StardewRule): if spot.access_rule is true_: access_rules = [Reach(spot.parent_region.name, "Region", rule.player)] @@ -149,13 +224,14 @@ def _(rule: Reach, state: CollectionState, expected: bool, explored_spots: Set[T access_rules = [*(Reach(e.name, "Entrance", rule.player) for e in spot.entrances)] if not access_rules: - return RuleExplanation(rule, state, expected, explored_rules_key=explored_spots) + return RuleExplanation(rule, state, expected, mode, more_explanations=more_explanations, explored_rules_key=explored_spots) - return RuleExplanation(rule, state, expected, access_rules, explored_rules_key=explored_spots) + return RuleExplanation(rule, state, expected, mode, access_rules, more_explanations=more_explanations, explored_rules_key=explored_spots) @_explain.register -def _(rule: Received, state: CollectionState, expected: bool, explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: +def _(rule: Received, state: CollectionState, expected: bool | None, mode: ExplainMode, more_explanations: list[StardewRule], + explored_spots: Set[Tuple[str, str]]) -> RuleExplanation: access_rules = None if rule.event: try: @@ -168,9 +244,9 @@ def _(rule: Received, state: CollectionState, expected: bool, explored_spots: Se pass if not access_rules: - return RuleExplanation(rule, state, expected, explored_rules_key=explored_spots) + return RuleExplanation(rule, state, expected, mode, more_explanations=more_explanations, explored_rules_key=explored_spots) - return RuleExplanation(rule, state, expected, access_rules, explored_rules_key=explored_spots) + return RuleExplanation(rule, state, expected, mode, access_rules, more_explanations=more_explanations, explored_rules_key=explored_spots) @singledispatch