diff --git a/CHANGELOG.md b/CHANGELOG.md index 55df668..dd56882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,12 @@ # Changelog -## [3.1.3a1](https://github.com/OpenVoiceOS/OVOS-workshop/tree/3.1.3a1) (2024-12-18) +## [3.2.0a1](https://github.com/OpenVoiceOS/OVOS-workshop/tree/3.2.0a1) (2024-12-18) -[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/3.1.2...3.1.3a1) +[Full Changelog](https://github.com/OpenVoiceOS/OVOS-workshop/compare/3.1.3...3.2.0a1) **Merged pull requests:** -- fix: intent layers [\#307](https://github.com/OpenVoiceOS/OVOS-workshop/pull/307) ([JarbasAl](https://github.com/JarbasAl)) +- feat: game skill [\#306](https://github.com/OpenVoiceOS/OVOS-workshop/pull/306) ([JarbasAl](https://github.com/JarbasAl)) diff --git a/ovos_workshop/locale/en/cant_load_game.dialog b/ovos_workshop/locale/en/cant_load_game.dialog new file mode 100644 index 0000000..c735397 --- /dev/null +++ b/ovos_workshop/locale/en/cant_load_game.dialog @@ -0,0 +1 @@ +the game can't be loaded \ No newline at end of file diff --git a/ovos_workshop/locale/en/cant_save_game.dialog b/ovos_workshop/locale/en/cant_save_game.dialog new file mode 100644 index 0000000..8b5bde5 --- /dev/null +++ b/ovos_workshop/locale/en/cant_save_game.dialog @@ -0,0 +1 @@ +the game can't be saved \ No newline at end of file diff --git a/ovos_workshop/locale/en/game_pause.dialog b/ovos_workshop/locale/en/game_pause.dialog new file mode 100644 index 0000000..ca89acf --- /dev/null +++ b/ovos_workshop/locale/en/game_pause.dialog @@ -0,0 +1 @@ +game paused \ No newline at end of file diff --git a/ovos_workshop/locale/en/game_unpause.dialog b/ovos_workshop/locale/en/game_unpause.dialog new file mode 100644 index 0000000..18aa2aa --- /dev/null +++ b/ovos_workshop/locale/en/game_unpause.dialog @@ -0,0 +1,2 @@ +unpausing game +resuming game \ No newline at end of file diff --git a/ovos_workshop/skills/common_play.py b/ovos_workshop/skills/common_play.py index 0e41a4d..f3bc0f4 100644 --- a/ovos_workshop/skills/common_play.py +++ b/ovos_workshop/skills/common_play.py @@ -1,13 +1,12 @@ import os from inspect import signature from threading import Event -from typing import List - -from ovos_utils import camel_case_split -from ovos_utils.log import LOG +from typing import List, Callable, Optional from ovos_bus_client import Message from ovos_config.locations import get_xdg_cache_save_path +from ovos_utils import camel_case_split +from ovos_utils.log import LOG from ovos_workshop.skills.ovos import OVOSSkill # backwards compat imports, do not delete, skills import from here @@ -56,23 +55,30 @@ def ... vocab for starting playback is needed. """ - def __init__(self, supported_media: List[MediaType] = None, + def __init__(self, *args, + supported_media: List[MediaType] = None, skill_icon: str = "", skill_voc_filename: str = "", - *args, **kwargs): + playback_handler: Optional[Callable[[Optional[Message]], None]] = None, + pause_handler: Optional[Callable[[Optional[Message]], None]] = None, + next_handler: Optional[Callable[[Optional[Message]], None]] = None, + prev_handler: Optional[Callable[[Optional[Message]], None]] = None, + resume_handler: Optional[Callable[[Optional[Message]], None]] = None, + **kwargs): self.supported_media = supported_media or [MediaType.GENERIC] self.skill_aliases = [] self.skill_voc_filename = skill_voc_filename self._search_handlers = [] # added via decorators self._featured_handlers = [] # added via decorators self._current_query = None - self.__playback_handler = None - self.__pause_handler = None - self.__next_handler = None - self.__prev_handler = None - self.__resume_handler = None + self.__playback_handler = playback_handler + self.__pause_handler = pause_handler + self.__next_handler = next_handler + self.__prev_handler = prev_handler + self.__resume_handler = resume_handler self._stop_event = Event() self._playing = Event() + self._paused = Event() # TODO new default icon self.skill_icon = skill_icon or "" @@ -387,17 +393,23 @@ def play_media(self, media, disambiguation=None, playlist=None): # @killable_event("ovos.common_play.stop", react_to_stop=True) def __handle_ocp_play(self, message): if self.__playback_handler: - self.__playback_handler(message) + params = signature(self.__playback_handler).parameters + kwargs = {"message": message} if "message" in params else {} + self.__playback_handler(**kwargs) self.bus.emit(Message("ovos.common_play.player.state", {"state": PlayerState.PLAYING})) self._playing.set() + self._paused.clear() else: LOG.error(f"Playback requested but {self.skill_id} handler not " "implemented") def __handle_ocp_pause(self, message): + self._paused.set() if self.__pause_handler: - if self.__pause_handler(message): + params = signature(self.__playback_handler).parameters + kwargs = {"message": message} if "message" in params else {} + if self.__pause_handler(**kwargs): self.bus.emit(Message("ovos.common_play.player.state", {"state": PlayerState.PAUSED})) else: @@ -405,8 +417,11 @@ def __handle_ocp_pause(self, message): "implemented") def __handle_ocp_resume(self, message): + self._paused.clear() if self.__resume_handler: - if self.__resume_handler(message): + params = signature(self.__playback_handler).parameters + kwargs = {"message": message} if "message" in params else {} + if self.__resume_handler(**kwargs): self.bus.emit(Message("ovos.common_play.player.state", {"state": PlayerState.PLAYING})) else: @@ -415,14 +430,18 @@ def __handle_ocp_resume(self, message): def __handle_ocp_next(self, message): if self.__next_handler: - self.__next_handler(message) + params = signature(self.__playback_handler).parameters + kwargs = {"message": message} if "message" in params else {} + self.__next_handler(**kwargs) else: LOG.error(f"Play Next requested but {self.skill_id} handler not " "implemented") def __handle_ocp_prev(self, message): if self.__prev_handler: - self.__prev_handler(message) + params = signature(self.__playback_handler).parameters + kwargs = {"message": message} if "message" in params else {} + self.__prev_handler(**kwargs) else: LOG.error(f"Play Next requested but {self.skill_id} handler not " "implemented") @@ -430,6 +449,7 @@ def __handle_ocp_prev(self, message): def __handle_ocp_stop(self, message): # for skills managing their own playback if self._playing.is_set(): + self._paused.clear() self.stop() self.gui.release() self.bus.emit(Message("ovos.common_play.player.state", @@ -543,6 +563,11 @@ def __handle_ocp_featured(self, message): "thumbnail": self.skill_icon, "playlist": results})) + def _handle_stop(self, message): + self._playing.clear() + self._paused.clear() + super()._handle_stop(message) + def default_shutdown(self): self.bus.emit( Message('ovos.common_play.skills.detach', diff --git a/ovos_workshop/skills/game_skill.py b/ovos_workshop/skills/game_skill.py new file mode 100644 index 0000000..ce99a51 --- /dev/null +++ b/ovos_workshop/skills/game_skill.py @@ -0,0 +1,265 @@ +import abc +from typing import Optional, Dict, Iterable + +from ovos_bus_client.message import Message +from ovos_bus_client.util import get_message_lang +from ovos_utils.ocp import MediaType, MediaEntry, PlaybackType, Playlist +from ovos_utils.parse import match_one, MatchStrategy + +from ovos_workshop.decorators import ocp_featured_media, ocp_search +from ovos_workshop.skills.common_play import OVOSCommonPlaybackSkill +from ovos_workshop.skills.ovos import _get_dialog + + +class OVOSGameSkill(OVOSCommonPlaybackSkill): + """ To integrate with the OpenVoiceOS Common Playback framework + + "play" intent is shared with media and managed by OCP pipeline + + The class makes the skill available to queries from OCP + - "skill_voc_filename" keyword argument is mandatory + it defines the .voc file containing the keywords to match the game name + + bus events emitted to trigger this skill: + - 'ovos.common_play.{self.skill_id}.play' + - 'ovos.common_play.{self.skill_id}.pause' + - 'ovos.common_play.{self.skill_id}.resume' + - 'ovos.common_play.{self.skill_id}.stop' + - 'ovos.common_play.{self.skill_id}.save' + - 'ovos.common_play.{self.skill_id}.load' + + """ + + def __init__(self, skill_voc_filename: str, + *args, + skill_icon: str = "", + game_image: str = "", + **kwargs): + """IMPORTANT: contents of skill_voc_filename are crucial for intent matching + without that ocp_pipeline might not recognize the skill as a game""" + self.game_image = game_image + super().__init__(skill_icon=skill_icon, skill_voc_filename=skill_voc_filename, + supported_media=[MediaType.GAME], + playback_handler=self.on_play_game, + pause_handler=self.on_pause_game, + resume_handler=self.on_resume_game, + *args, **kwargs) + + @ocp_featured_media() + def _ocp_featured(self) -> Playlist: + """ensure skill shows up in OCP GUI menu + report the game as the only featured_track""" + entry = MediaEntry( + uri=f"skill:{self.skill_id}", + title=self.skill_aliases[0], + image=self.game_image, + playback=PlaybackType.SKILL, + media_type=MediaType.GAME, + match_confidence=100, + skill_icon=self.skill_icon + ) + pl = Playlist( + title=self.skill_aliases[0], + image=self.game_image, + playback=PlaybackType.SKILL, + media_type=MediaType.GAME, + match_confidence=100, + skill_icon=self.skill_icon) + pl.add_entry(entry) + return pl + + @ocp_search() + def _ocp_search(self, phrase: str, media_type: MediaType) -> Iterable[MediaEntry]: + """match the game name when OCP is searching""" + if media_type != MediaType.GAME: + # only match if ocp_pipeline determined a game was wanted + return + + _, score = match_one(phrase, self.skill_aliases, + strategy=MatchStrategy.DAMERAU_LEVENSHTEIN_SIMILARITY) + conf = int(score * 100) + if conf >= 50: + entry = self._ocp_featured()[0] + entry.match_confidence = conf + yield entry + + @property + def is_playing(self) -> bool: + return self._playing.is_set() + + @property + def is_paused(self) -> bool: + return self._paused.is_set() + + @abc.abstractmethod + def on_play_game(self): + """called by ocp_pipeline when 'play XXX' matches the game""" + + @abc.abstractmethod + def on_pause_game(self): + """called by ocp_pipeline on 'pause' if game is being played""" + + @abc.abstractmethod + def on_resume_game(self): + """called by ocp_pipeline on 'resume/unpause' if game is being played and paused""" + + @abc.abstractmethod + def on_stop_game(self): + """called when game is stopped for any reason + auto-save may be implemented here""" + + @abc.abstractmethod + def on_save_game(self): + """if your game has no save/load functionality you should + speak a error dialog here""" + + @abc.abstractmethod + def on_load_game(self): + """if your game has no save/load functionality you should + speak a error dialog here""" + + def stop(self) -> bool: + if self.is_playing: + self.on_stop_game() + return True + return False + + def calc_intent(self, utterance: str, lang: str) -> Optional[Dict[str, str]]: + """helper to check what intent would be selected by ovos-core""" + # let's see what intent ovos-core will assign to the utterance + # NOTE: converse, common_query and fallbacks are not included in this check + response = self.bus.wait_for_response(Message("intent.service.intent.get", + {"utterance": utterance, "lang": lang}), + "intent.service.intent.reply", + timeout=1.0) + if not response: + return None + return response.data["intent"] + + +class ConversationalGameSkill(OVOSGameSkill): + + def on_save_game(self): + """skills can override method to implement functioonality""" + speech = _get_dialog("cant_save_game", self.lang) + self.speak(speech) + + def on_load_game(self): + """skills can override method to implement functioonality""" + speech = _get_dialog("cant_load_game", self.lang) + self.speak(speech) + + def on_pause_game(self): + """called by ocp_pipeline on 'pause' if game is being played""" + self._paused.set() + self.acknowledge() + # individual skills can change default value if desired + if self.settings.get("pause_dialog", False): + speech = _get_dialog("game_pause", self.lang) + self.speak(speech) + + def on_resume_game(self): + """called by ocp_pipeline on 'resume/unpause' if game is being played and paused""" + self._paused.clear() + self.acknowledge() + # individual skills can change default value if desired + if self.settings.get("pause_dialog", False): + speech = _get_dialog("game_unpause", self.lang) + self.speak(speech) + + @abc.abstractmethod + def on_play_game(self): + """called by ocp_pipeline when 'play XXX' matches the game""" + + @abc.abstractmethod + def on_stop_game(self): + """called when game is stopped for any reason + auto-save may be implemented here""" + + @abc.abstractmethod + def on_game_command(self, utterance: str, lang: str): + """pipe user input that wasnt caught by intents to the game + do any intent matching or normalization here + don't forget to self.speak the game output too! + """ + + @abc.abstractmethod + def on_abandon_game(self): + """user abandoned game mid interaction + + auto-save is done before this method is called + (if enabled in self.settings) + + on_game_stop will be called after this handler""" + + # converse + def skill_will_trigger(self, utterance: str, lang: str, skill_id: Optional[str] = None) -> bool: + """helper to check if this skill would be selected by ovos-core with the given utterance + + useful in converse method + eg. return not self.will_trigger + + this example allows the utterance to be consumed via converse of using ovos-core intent parser + ensuring it is always handled by the game skill regardless + """ + # determine if an intent from this skill + # will be selected by ovos-core + id_to_check = skill_id or self.skill_id + intent = self.calc_intent(utterance, lang) + skill_id = intent["skill_id"] if intent else "" + return skill_id == id_to_check + + @property + def save_is_implemented(self) -> bool: + """ + True if this skill implements a `save` method + """ + return self.__class__.on_save_game is not ConversationalGameSkill.on_save_game + + def _autosave(self): + """helper to save the game automatically if enabled in settings.json and implemented by skill""" + if self.settings.get("auto_save", False) and self.save_is_implemented: + self.on_save_game() + + def converse(self, message: Message): + try: + if self.is_paused: + # let ocp_pipeline unpause as appropriate + return False + + self._autosave() + utterance = message.data["utterances"][0] + lang = get_message_lang(message) + # let the user implemented intents do the job if they can handle the utterance + if self.is_playing and not self.skill_will_trigger(utterance, lang): + # otherwise pipe utterance to the game handler + self.on_game_command(utterance, lang) + return True + return False + except (KeyError, IndexError) as e: + self.log.error(f"Error processing converse message: {e}") + return False + except Exception as e: + self.log.exception(f"Unexpected error in converse: {e}") + return False + + def handle_deactivate(self, message: Message): + """ + Called when this skill is no longer considered active by the intent service; + means the user didn't interact with the game for a long time and intent parser will be released + """ + try: + if self.is_paused: + self.log.info("Game is paused, keeping it active") + self.activate() # keep the game in active skills list so it can still converse + elif self.is_playing: + self._autosave() + self.log.info("Game abandoned due to inactivity") + self.on_abandon_game() + self.on_stop_game() + except Exception as e: + self.log.exception(f"Error during game deactivation: {e}") + + def stop(self) -> bool: + self._autosave() + return super().stop() diff --git a/ovos_workshop/skills/ovos.py b/ovos_workshop/skills/ovos.py index 2de9df5..065d9dc 100644 --- a/ovos_workshop/skills/ovos.py +++ b/ovos_workshop/skills/ovos.py @@ -1076,7 +1076,7 @@ def _register_system_event_handlers(self): """ Register default messagebus event handlers """ - self.add_event('mycroft.stop', self.__handle_stop, speak_errors=False) + self.add_event('mycroft.stop', self._handle_stop, speak_errors=False) self.add_event(f"{self.skill_id}.stop", self._handle_session_stop, speak_errors=False) self.add_event(f"{self.skill_id}.stop.ping", self._handle_stop_ack, speak_errors=False) @@ -1264,7 +1264,7 @@ def _handle_session_stop(self, message: Message): self.bus.emit(message.reply(f"{self.skill_id}.stop.response", data)) - def __handle_stop(self, message): + def _handle_stop(self, message): """Handler for the "mycroft.stop" signal. Runs the user defined `stop()` method. """ diff --git a/ovos_workshop/version.py b/ovos_workshop/version.py index d437ab3..1c924d6 100644 --- a/ovos_workshop/version.py +++ b/ovos_workshop/version.py @@ -1,6 +1,6 @@ # START_VERSION_BLOCK VERSION_MAJOR = 3 -VERSION_MINOR = 1 -VERSION_BUILD = 3 -VERSION_ALPHA = 0 +VERSION_MINOR = 2 +VERSION_BUILD = 0 +VERSION_ALPHA = 1 # END_VERSION_BLOCK