Spaces:
Sleeping
Sleeping
| """ | |
| 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": "<action_type>", | |
| "building_type": "<valeur ou omis>", | |
| "unit_type": "<valeur ou omis>", | |
| "count": <entier ou omis: pour train=nb à produire; pour move/attack/patrol/stop/gather/siege/cloak=nb max d'unités à sélectionner>, | |
| "unit_selector": "<valeur ou omis>", | |
| "target_zone": "<valeur ou omis>", | |
| "resource_type": "<'minerals' ou 'gas', omis sinon>", | |
| "group_index": <1, 2 ou 3 pour assign_to_group, omis sinon>, | |
| "unit_ids": [] | |
| } | |
| ], | |
| "feedback_template": "<phrase avec placeholders {n}, {zone}, etc. dans la langue du joueur>", | |
| "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.") | |