File size: 9,432 Bytes
07ed12b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244fb73
07ed12b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29a88f8
 
 
 
 
 
 
 
 
68097bf
07ed12b
5c0862e
 
5503c3f
07ed12b
 
 
 
 
dd96d2f
07ed12b
29a88f8
 
 
 
 
 
 
07ed12b
 
 
 
 
 
dd96d2f
 
 
07ed12b
 
 
 
 
 
 
 
29a88f8
07ed12b
 
dd96d2f
5c0862e
dd96d2f
07ed12b
 
dd96d2f
 
07ed12b
 
 
 
dd96d2f
 
 
 
29a88f8
dd96d2f
07ed12b
 
 
dd96d2f
 
29a88f8
07ed12b
 
 
 
 
dd96d2f
 
 
 
29a88f8
 
 
 
 
 
 
dd96d2f
 
 
 
29a88f8
dd96d2f
07ed12b
 
244fb73
07ed12b
 
 
 
 
 
 
 
 
 
 
 
 
 
dd96d2f
 
 
 
07ed12b
 
dd96d2f
 
 
07ed12b
 
dd96d2f
 
 
 
 
 
07ed12b
 
dd96d2f
 
07ed12b
dd96d2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""
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.")