""" Voice command parser — Mistral LLM interprets transcribed text and returns structured GameActions. The player's current game state is injected as context so Mistral can understand references like "envoie tous mes marines" or "construis quelque chose pour produire des tanks". """ from __future__ import annotations import json import logging from mistralai import Mistral from config import MISTRAL_API_KEY, MISTRAL_CHAT_MODEL from game.commands import ActionType, GameAction, ParsedCommand from game.state import PlayerState log = logging.getLogger(__name__) _SYSTEM_PROMPT = """\ Tu es l'interprète de commandes vocales d'un jeu de stratégie en temps réel (style StarCraft, race Terran). Ton rôle est de convertir les instructions vocales d'un joueur en actions JSON structurées. === UNITÉS DISPONIBLES === scv, marine, medic, goliath, tank, wraith === BÂTIMENTS DISPONIBLES === supply_depot, barracks, engineering_bay, refinery, factory, armory, starport === TYPES D'ACTIONS === - build : construire un bâtiment → champs: building_type - train : entraîner des unités → champs: unit_type, count (défaut 1) - move : déplacer des unités → champs: unit_selector, target_zone, count (optionnel: limite le nb d'unités sélectionnées, ex: "envoie 2 SCVs" → count:2) - attack : attaquer une zone → champs: unit_selector, target_zone, count (optionnel) - siege : tank en mode siège → champs: unit_selector (optionnel), count (optionnel) - unsiege : retirer le mode siège → champs: unit_selector (optionnel), count (optionnel) - cloak : activer camouflage → champs: unit_selector (optionnel), count (optionnel) - decloak : désactiver camouflage → champs: unit_selector (optionnel), count (optionnel) - gather : collecter ressources → champs: unit_selector, resource_type ("minerals"|"gas"), count (optionnel) - stop : arrêter des unités → champs: unit_selector, count (optionnel) - patrol : patrouiller → champs: unit_selector, target_zone, count (optionnel) - defend : défendre une base (unités militaires patrouillent en cercle autour de la zone en attaquant automatiquement) → champs: unit_selector (optionnel), target_zone (défaut: my_base), count (optionnel). Utiliser quand le joueur dit "défends ma base", "protège la base", "garde la base", "defend my base", "hold the base", etc. - query : information sur l'état → aucun champ requis - query_units : lister les IDs d'unités (pour affectation aux groupes) → champs optionnels: target_zone, unit_type - assign_to_group : affecter des unités à un groupe (1, 2 ou 3) → champs: group_index (1-3). Utilise le résultat du query_units précédent si unit_ids vide. Pour "mets mes marines dans le groupe 1", génère d'abord query_units (unit_type: marine) puis assign_to_group (group_index: 1). - resign : abandonner la partie (l'adversaire gagne) → aucun champ requis. Utiliser quand le joueur dit "j'abandonne", "je capitule", "je me rends", "I resign", "I give up", etc. === SÉLECTEURS D'UNITÉS === all, all_military, all_marines, all_medics, all_goliaths, all_tanks, all_wraiths, all_scv, idle_scv, most_damaged === ZONES CIBLES === my_base, enemy_base, center, top_left, top_right, bottom_left, bottom_right, front_line{resource_zones} Lieux nommés visibles sur la carte (utiliser le slug comme target_zone) :{landmark_zones} Positions horloges (cadran 12h sur la carte) : 12h = haut-centre, 3h = centre-droit, 6h = bas-centre, 9h = centre-gauche Valeurs intermédiaires acceptées : 1h, 2h, 4h, 5h, 7h, 8h, 10h, 11h, 1h30, 2h30, etc. Exemples : "envoie les marines à 3h" → target_zone: "3h" ; "attaque à 11h" → target_zone: "11h" === ÉTAT ACTUEL DU JOUEUR === {player_state} === CONSIGNES === - Réponds UNIQUEMENT avec un JSON valide, aucun texte avant ou après. - Une commande peut générer PLUSIEURS actions (ex: "entraîne 4 marines et attaque la base"). - Le champ "feedback_template" est une phrase courte DANS LA MÊME LANGUE que la commande, avec des placeholders pour les infos du jeu. Placeholders possibles: {n} (nombre d'unités), {zone}, {building}, {count}, {unit}, {resource}, {summary}, {mode}, {names}, {raw}. Ex: "Déplacement de {n} unités vers {zone}." ou "État: {summary}". - Ajoute le champ "language" avec le code ISO de la langue: "fr", "en". - Si la commande est incompréhensible, génère une action "query" avec feedback_template explicatif (ex: "Je n'ai pas compris. Voici ton état: {summary}"). === FORMAT DE RÉPONSE === { "actions": [ { "type": "", "building_type": "", "unit_type": "", "count": , "unit_selector": "", "target_zone": "", "resource_type": "<'minerals' ou 'gas', omis sinon>", "group_index": <1, 2 ou 3 pour assign_to_group, omis sinon>, "unit_ids": [] } ], "feedback_template": "", "language": "fr" } """ async def parse( transcription: str, player: PlayerState, resource_zones: list[str] | None = None, landmarks: list[dict] | None = None, ) -> ParsedCommand: """ Send transcription + player state to Mistral and return parsed command. Falls back to a query action if parsing fails. resource_zones: list of zone names like ["mineral_1", ..., "geyser_1", ...] sorted by proximity to the player's base. landmarks: list of MAP_LANDMARKS dicts with slug/name/description keys. """ if not MISTRAL_API_KEY: raise RuntimeError("MISTRAL_API_KEY not set") client = Mistral(api_key=MISTRAL_API_KEY) if resource_zones: rz_str = ", " + ", ".join(resource_zones) else: rz_str = "" if landmarks: lm_lines = "\n" + "\n".join( f' - {lm["slug"]} : {lm["name"]} ({lm["description"]})' for lm in landmarks ) else: lm_lines = "" system = ( _SYSTEM_PROMPT .replace("{player_state}", player.summary()) .replace("{resource_zones}", rz_str) .replace("{landmark_zones}", lm_lines) ) response = await client.chat.complete_async( model=MISTRAL_CHAT_MODEL, messages=[ {"role": "system", "content": system}, {"role": "user", "content": transcription}, ], response_format={"type": "json_object"}, temperature=0.1, ) raw = response.choices[0].message.content or "{}" log.info("Mistral raw response: %s", raw[:300]) try: data = json.loads(raw) actions = [GameAction(**a) for a in data.get("actions", [])] language = (data.get("language") or "fr").strip().lower() or "fr" if language not in ("fr", "en"): language = "fr" feedback_template = data.get("feedback_template") or data.get("feedback") or "{summary}" if not actions: raise ValueError("Empty actions list") return ParsedCommand( actions=actions, feedback_template=feedback_template, language=language ) except Exception as exc: log.warning("Failed to parse Mistral response: %s — %s", exc, raw[:200]) lang = _detect_language(transcription) fallback_template = ( "I didn't understand. Here is your status: {summary}" if lang == "en" else "Je n'ai pas compris. Voici ton état: {summary}" ) return ParsedCommand( actions=[GameAction(type=ActionType.QUERY)], feedback_template=fallback_template, language=lang, ) def _detect_language(text: str) -> str: """Heuristic: if transcription looks like English, return 'en', else 'fr'.""" if not text or not text.strip(): return "fr" lower = text.lower().strip() en_words = { "build", "train", "attack", "move", "send", "create", "gather", "stop", "patrol", "go", "select", "unit", "units", "base", "minerals", "gas", "barracks", "factory", "scv", "marine", "tank", "all", "my", "the", } words = set(lower.split()) if words & en_words: return "en" return "fr" async def generate_feedback(error_key: str, language: str) -> str: """ Call the API to generate a short feedback message for an error, in the given language. error_key: e.g. "game_not_in_progress" """ if not MISTRAL_API_KEY: return "Game is not in progress." if language == "en" else "La partie n'est pas en cours." client = Mistral(api_key=MISTRAL_API_KEY) prompt = f"""Generate exactly one short sentence for a strategy game feedback. Context/error: {error_key}. Language: {language}. Reply with only that sentence, no quotes, no explanation.""" response = await client.chat.complete_async( model=MISTRAL_CHAT_MODEL, messages=[{"role": "user", "content": prompt}], temperature=0.2, max_tokens=80, ) text = (response.choices[0].message.content or "").strip() return text or ("Game is not in progress." if language == "en" else "La partie n'est pas en cours.")