22from typing import Optional , Dict , Iterable
33
44from ovos_bus_client .message import Message
5+ from ovos_bus_client .session import SessionManager
56from ovos_bus_client .util import get_message_lang
67from ovos_utils .ocp import MediaType , MediaEntry , PlaybackType , Playlist , PlayerState
78from ovos_utils .parse import match_one , MatchStrategy
89
910from ovos_workshop .decorators import ocp_featured_media , ocp_search
1011from ovos_workshop .skills .common_play import OVOSCommonPlaybackSkill
12+ from ovos_workshop .skills .converse import ConversationalSkill
1113
1214
13- class OVOSGameSkill (OVOSCommonPlaybackSkill ):
15+ class OVOSGameSkill (OVOSCommonPlaybackSkill , ConversationalSkill ):
1416 """ To integrate with the OpenVoiceOS Common Playback framework
1517
1618 "play" intent is shared with media and managed by OCP pipeline
@@ -82,13 +84,8 @@ def _ocp_search(self, phrase: str, media_type: MediaType) -> Iterable[MediaEntry
8284 entry .match_confidence = conf
8385 yield entry
8486
85- @property
86- def is_playing (self ) -> bool :
87- return self ._playing .is_set ()
88-
89- @property
90- def is_paused (self ) -> bool :
91- return self ._paused .is_set ()
87+ # is_playing / is_paused (and per-session variants) are provided by
88+ # OVOSCommonPlaybackSkill and are session aware.
9289
9390 @abc .abstractmethod
9491 def on_play_game (self ):
@@ -119,13 +116,14 @@ def on_load_game(self):
119116
120117 def stop_game (self ):
121118 """to be called by skills if they want to stop game programatically"""
122- if self .is_playing :
123- self ._paused .clear ()
119+ sid = self .get_session_id ()
120+ if self .is_playing_in (sid ):
121+ self ._paused_sessions .discard (sid )
124122 self .gui .release ()
125123 self .log .debug ("changing OCP state: PlayerState.STOPPED " )
126124 self .bus .emit (Message ("ovos.common_play.player.state" ,
127125 {"state" : PlayerState .STOPPED }))
128- self ._playing . clear ( )
126+ self ._playing_sessions . discard ( sid )
129127 self .on_stop_game ()
130128 return True
131129 return False
@@ -134,17 +132,35 @@ def stop(self) -> bool:
134132 """NOTE: not meant to be called by the skill, this is a callback"""
135133 return self .stop_game ()
136134
135+ # pipelines that are NOT a real intent match (they should not count when a
136+ # game asks "would an intent be selected for this utterance?")
137+ _NON_INTENT_PIPELINES = ("converse" , "fallback" , "common_query" , "stop" )
138+
137139 def calc_intent (self , utterance : str , lang : str , timeout = 1.0 ) -> Optional [Dict [str , str ]]:
138- """helper to check what intent would be selected by ovos-core"""
140+ """helper to check what intent would be selected by ovos-core
141+
142+ NOTE: converse, common_query, stop and fallbacks are intentionally
143+ ignored here - this reports only a genuine intent-parser match (adapt /
144+ padatious / padacioso), which is what gates whether a layer intent will
145+ fire for the utterance.
146+ """
139147 # let's see what intent ovos-core will assign to the utterance
140- # NOTE: converse, common_query and fallbacks are not included in this check
141148 response = self .bus .wait_for_response (Message ("intent.service.intent.get" ,
142149 {"utterance" : utterance , "lang" : lang }),
143150 "intent.service.intent.reply" ,
144151 timeout = timeout )
145152 if not response :
146153 return None
147- return response .data ["intent" ]
154+ intent = response .data .get ("intent" )
155+ if not intent :
156+ return None
157+ # the full pipeline runs converse first; a game is "active" so converse
158+ # would always claim the utterance. Ignore non-intent-parser pipelines so
159+ # this reflects whether a *layer intent* would actually match.
160+ pipeline = (intent .get ("intent_service" ) or "" ).lower ()
161+ if any (p in pipeline for p in self ._NON_INTENT_PIPELINES ):
162+ return None
163+ return intent
148164
149165
150166class ConversationalGameSkill (OVOSGameSkill ):
@@ -158,16 +174,20 @@ def on_load_game(self):
158174 self .speak_dialog ("cant_load_game" )
159175
160176 def on_pause_game (self ):
161- """called by ocp_pipeline on 'pause' if game is being played"""
162- self ._paused .set ()
177+ """called by ocp_pipeline on 'pause' if game is being played
178+
179+ NOTE: the per-session paused state is already set by the OCP framework
180+ before this handler runs."""
163181 self .acknowledge ()
164182 # individual skills can change default value if desired
165183 if self .settings .get ("pause_dialog" , False ):
166184 self .speak_dialog ("game_pause" )
167185
168186 def on_resume_game (self ):
169- """called by ocp_pipeline on 'resume/unpause' if game is being played and paused"""
170- self ._paused .clear ()
187+ """called by ocp_pipeline on 'resume/unpause' if game is being played and paused
188+
189+ NOTE: the per-session paused state is already cleared by the OCP
190+ framework before this handler runs."""
171191 self .acknowledge ()
172192 # individual skills can change default value if desired
173193 if self .settings .get ("pause_dialog" , False ):
@@ -183,10 +203,14 @@ def on_stop_game(self):
183203 auto-save may be implemented here"""
184204
185205 @abc .abstractmethod
186- def on_game_command (self , utterance : str , lang : str ):
206+ def on_game_command (self , utterance : str , lang : str ,
207+ message : Optional [Message ] = None ):
187208 """pipe user input that wasnt caught by intents to the game
188209 do any intent matching or normalization here
189210 don't forget to self.speak the game output too!
211+
212+ @param message: the utterance Message (carries the session); use it to
213+ key per-session game state so several sessions can play at once.
190214 """
191215
192216 def on_abandon_game (self ):
@@ -198,6 +222,34 @@ def on_abandon_game(self):
198222 on_game_stop will be called after this handler"""
199223
200224 # converse
225+ def can_converse (self , message : Message ) -> bool :
226+ """A game wants to converse while it is being played (or paused), EXCEPT
227+ when one of its own context-gated layer intents would match the
228+ utterance - those must be handled by the intent pipeline (adapt), not
229+ swallowed by converse.
230+
231+ This lets the converse pipeline route only the free-form input that no
232+ layer intent matched into `on_game_command`.
233+ """
234+ if not (self .is_playing or self .is_paused ):
235+ return False
236+ try :
237+ utterance = message .data .get ("utterances" , ["" ])[0 ]
238+ lang = get_message_lang (message )
239+ # The converse pipeline runs *before* adapt, so while a game is
240+ # active converse would otherwise always claim the utterance and
241+ # prevent layer intents from matching. Probe the intent service
242+ # (read-only) excluding the converse stage (to avoid re-entering
243+ # this very check); if one of our context-gated layer intents would
244+ # match, let adapt handle it instead of swallowing it here.
245+ if utterance and self .skill_will_match (
246+ utterance , lang , exclude_pipeline = ["ovos-converse-pipeline-plugin" ],
247+ session = SessionManager .get (message )):
248+ return False
249+ except Exception as e :
250+ self .log .debug (f"can_converse intent-gate failed: { e } " )
251+ return True
252+
201253 def skill_will_trigger (self , utterance : str , lang : str , skill_id : Optional [str ] = None , timeout = 0.8 ) -> bool :
202254 """helper to check if this skill would be selected by ovos-core with the given utterance
203255
@@ -230,15 +282,18 @@ def _async_cmd(self, message: Message):
230282 utterance = message .data ["utterances" ][0 ]
231283 lang = get_message_lang (message )
232284 self .log .debug (f"Piping utterance to game: { utterance } " )
233- self .on_game_command (utterance , lang )
285+ self .on_game_command (utterance , lang , message )
234286
235287 def converse (self , message : Message ) -> bool :
236288 try :
237289 utterance = message .data ["utterances" ][0 ]
238290 lang = get_message_lang (message )
239- # let the user implemented intents do the job if they can handle the utterance
240- # otherwise pipe utterance to the game handler
241- if self .skill_will_trigger (utterance , lang ):
291+ # let the user implemented (context-gated) intents do the job if they
292+ # can handle the utterance, otherwise pipe utterance to the game
293+ # handler. Probe excludes converse to avoid re-entrancy.
294+ if self .skill_will_match (utterance , lang ,
295+ exclude_pipeline = ["ovos-converse-pipeline-plugin" ],
296+ session = SessionManager .get (message )):
242297 self .log .debug ("Skill intent will trigger, don't pipe utterance to game" )
243298 return False
244299
0 commit comments