FastMCP-Server (stdio + Streamable HTTP) mit 13 sc_-Tools fuer das Lesen und Schreiben von StarCraft-Brood-War-Karten (.scm/.scx) ueber RichChk: - sc_list_maps, sc_describe_map, sc_list_locations, sc_list_triggers (lesen) - sc_create_location, sc_rename_location, sc_set_player_setup - sc_add_trigger (Kern-Tool: 10 Condition- und 20 Action-Typen), sc_remove_trigger, sc_clear_triggers - sc_embed_wav (Voiceover/Sound-Einbettung), sc_save_map, sc_reset_map Tolerante Pydantic-Eingaben, hilfreiche Fehlermeldungen, readOnly/destructive-Hints. Karten-Sitzung im Speicher pro Basis-Karte; Basis-Karte bleibt unveraendert. Deployment: Dockerfile (python:3.12-slim), docker-compose, Caddyfile, run.sh. Beispiel-Basis-Karte in data/maps. Selbsttest beweist die ganze Pipeline. Verifiziert: Pipeline + alle Tools + WAV-Einbettung + Streamable-HTTP-Handshake (echter MCP-Client), unter Python 3.11 und 3.12. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaZBZofC1on2gkSMb5UWZy
452 lines
19 KiB
Python
452 lines
19 KiB
Python
"""Bausteine fuer Trigger: Pydantic-Eingaben -> echte RichChk-Objekte.
|
|
|
|
Das ist das Herzstueck. Ein Trigger besteht aus *Bedingungen* (conditions) und
|
|
*Aktionen* (actions). Hier werden die tolerant getippten Eingaben von Claude in die
|
|
echten RichChk-Modelle uebersetzt, mit hilfreichen Fehlermeldungen.
|
|
|
|
Unterstuetzte CONDITION-Typen (Feld `type`):
|
|
- always : immer wahr (Standard-Bedingung)
|
|
- never : nie wahr
|
|
- elapsed_time : seit Spielstart sind N Sekunden vergangen
|
|
Felder: seconds, comparator (AT_LEAST|AT_MOST|EXACTLY)
|
|
- countdown_timer : der Countdown-Timer vergleicht mit N Sekunden
|
|
Felder: seconds, comparator
|
|
- bring : Spieler hat N Einheiten an einer Location
|
|
Felder: player, comparator, amount, unit, location
|
|
- command : Spieler kommandiert N Einheiten (ganze Karte)
|
|
Felder: player, comparator, amount, unit
|
|
- kill : Spieler hat N Einheiten eines Typs getoetet
|
|
Felder: player, comparator, amount, unit
|
|
- deaths : Spieler hat N Todesfaelle eines Einheitentyps
|
|
Felder: player, comparator, amount, unit
|
|
- accumulate : Spieler hat N Ressourcen
|
|
Felder: player, comparator, amount, resource (ORE|GAS|ORE_AND_GAS)
|
|
- opponents : Spieler hat N verbleibende Gegner
|
|
Felder: player, comparator, amount
|
|
|
|
Unterstuetzte ACTION-Typen (Feld `type`):
|
|
- display_text : Text einblenden. Felder: text
|
|
- set_mission_objectives : Missionsziele setzen. Felder: text
|
|
- play_wav : WAV abspielen. Felder: wav_path (in-MPQ-Pfad), duration_ms?
|
|
- create_unit : Einheiten erzeugen. Felder: player, amount, unit, location
|
|
- kill_unit_at_location : Einheiten toeten. Felder: player, amount, unit, location
|
|
- remove_unit_at_location: Einheiten entfernen. Felder: player, amount, unit, location
|
|
- move_unit : Einheiten bewegen.
|
|
Felder: player, amount, unit, location (Quelle), destination (Ziel)
|
|
- give_units : Einheiten uebergeben.
|
|
Felder: player (von), target_player (an), amount, unit, location
|
|
- set_resources : Ressourcen setzen.
|
|
Felder: player, modifier (SET_TO|ADD|SUBTRACT), amount, resource
|
|
- set_deaths : Todeszaehler setzen.
|
|
Felder: player, modifier, amount, unit
|
|
- set_countdown_timer : Countdown-Timer setzen. Felder: seconds, modifier
|
|
- pause_timer / unpause_timer : Countdown-Timer pausieren/fortsetzen
|
|
- run_ai_script : AI-Skript ausfuehren. Felder: ai_script (4-Zeichen-Code)
|
|
- run_ai_script_at_location : AI-Skript an Location. Felder: ai_script, location
|
|
- center_view : Kamera zentrieren. Felder: location
|
|
- minimap_ping : Minimap-Ping. Felder: location
|
|
- set_alliance_status : Allianz setzen.
|
|
Felder: player (Ziel-Gruppe), alliance_status (ENEMY|ALLY|ALLIED_VICTORY)
|
|
- victory : Mission gewonnen
|
|
- defeat : Mission verloren
|
|
|
|
Hinweis "Transmission mit Portrait": RichChk hat (Stand dieser Version) kein
|
|
High-Level-Modell fuer die Transmission-/TalkingPortrait-Aktion. Baue einen
|
|
Funkspruch stattdessen aus `play_wav` + `display_text` (+ optional `center_view`)
|
|
zusammen. Siehe README.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
from richchk.model.richchk.mrgn.rich_location import RichLocation
|
|
from richchk.model.richchk.str.rich_string import RichString
|
|
from richchk.model.richchk.trig.actions.center_view_action import CenterViewAction
|
|
from richchk.model.richchk.trig.actions.create_unit_action import CreateUnitAction
|
|
from richchk.model.richchk.trig.actions.defeat_action import DefeatAction
|
|
from richchk.model.richchk.trig.actions.display_text_message_action import (
|
|
DisplayTextMessageAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.give_unit_action import GiveUnitAction
|
|
from richchk.model.richchk.trig.actions.kill_unit_at_location_action import (
|
|
KillUnitAtLocationAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.minimap_ping_action import MinimapPingAction
|
|
from richchk.model.richchk.trig.actions.move_unit_action import MoveUnitAction
|
|
from richchk.model.richchk.trig.actions.pause_countdown_timer import (
|
|
PauseCountdownTimerAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.play_wav_action import PlayWavAction
|
|
from richchk.model.richchk.trig.actions.remove_unit_at_location_action import (
|
|
RemoveUnitAtLocationAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.run_ai_script_action import RunAiScriptAction
|
|
from richchk.model.richchk.trig.actions.run_ai_script_at_location_action import (
|
|
RunAiScriptAtLocationAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.set_countdown_timer import (
|
|
SetCountdownTimerAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.set_alliance_status_action import (
|
|
SetAllianceStatusAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.set_deaths_action import SetDeathsAction
|
|
from richchk.model.richchk.trig.actions.set_mission_objectives_action import (
|
|
SetMissionObjectivesAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.set_resources_action import SetResourcesAction
|
|
from richchk.model.richchk.trig.actions.unpause_countdown_timer import (
|
|
UnpauseCountdownTimerAction,
|
|
)
|
|
from richchk.model.richchk.trig.actions.victory_action import VictoryAction
|
|
from richchk.model.richchk.trig.conditions.accumulate_resources_condition import (
|
|
AccumulateResourcesCondition,
|
|
)
|
|
from richchk.model.richchk.trig.conditions.always_condition import AlwaysCondition
|
|
from richchk.model.richchk.trig.conditions.bring_condition import BringCondition
|
|
from richchk.model.richchk.trig.conditions.command_condition import CommandCondition
|
|
from richchk.model.richchk.trig.conditions.countdown_timer_condition import (
|
|
CountdownTimerCondition,
|
|
)
|
|
from richchk.model.richchk.trig.conditions.deaths_condition import DeathsCondition
|
|
from richchk.model.richchk.trig.conditions.elapsed_time_condition import (
|
|
ElapsedTimeCondition,
|
|
)
|
|
from richchk.model.richchk.trig.conditions.kill_condition import KillCondition
|
|
from richchk.model.richchk.trig.conditions.never_condition import NeverCondition
|
|
from richchk.model.richchk.trig.conditions.opponents_remaining_condition import (
|
|
OpponentsRemainingCondition,
|
|
)
|
|
from richchk.model.richchk.trig.enums.ai_script import AiScript, KnownAiScript
|
|
|
|
from . import enums
|
|
|
|
# Locations werden ueber einen Resolver (Name -> RichLocation) aufgeloest, den der
|
|
# Aufrufer injiziert, weil die verfuegbaren Locations vom Workspace abhaengen.
|
|
LocationResolver = Callable[[str], RichLocation]
|
|
|
|
|
|
class Condition(BaseModel):
|
|
"""Eine einzelne Trigger-Bedingung. Pflichtfelder haengen von `type` ab."""
|
|
|
|
type: str = Field(
|
|
description="Bedingungstyp: always, never, elapsed_time, countdown_timer, "
|
|
"bring, command, kill, deaths, accumulate, opponents."
|
|
)
|
|
player: Optional[str] = Field(
|
|
default=None,
|
|
description="Spieler/Gruppe, z.B. PLAYER_1, ALL_PLAYERS, FOES, CURRENT_PLAYER.",
|
|
)
|
|
comparator: str = Field(
|
|
default="AT_LEAST",
|
|
description="Vergleich: AT_LEAST, AT_MOST oder EXACTLY.",
|
|
)
|
|
amount: Optional[int] = Field(default=None, description="Menge/Anzahl/Ressourcen.")
|
|
unit: Optional[str] = Field(
|
|
default=None, description="Einheit, z.B. TERRAN_MARINE oder 'Terran Marine'."
|
|
)
|
|
location: Optional[str] = Field(
|
|
default=None, description="Name einer existierenden Location."
|
|
)
|
|
seconds: Optional[int] = Field(default=None, description="Sekunden (Zeit-Typen).")
|
|
resource: Optional[str] = Field(
|
|
default=None, description="Ressource: ORE, GAS oder ORE_AND_GAS."
|
|
)
|
|
|
|
|
|
class Action(BaseModel):
|
|
"""Eine einzelne Trigger-Aktion. Pflichtfelder haengen von `type` ab."""
|
|
|
|
type: str = Field(
|
|
description="Aktionstyp: display_text, set_mission_objectives, play_wav, "
|
|
"create_unit, kill_unit_at_location, remove_unit_at_location, move_unit, "
|
|
"give_units, set_resources, set_deaths, set_countdown_timer, pause_timer, "
|
|
"unpause_timer, run_ai_script, run_ai_script_at_location, center_view, "
|
|
"minimap_ping, set_alliance_status, victory, defeat."
|
|
)
|
|
text: Optional[str] = Field(default=None, description="Anzuzeigender Text.")
|
|
wav_path: Optional[str] = Field(
|
|
default=None,
|
|
description="In-MPQ-Pfad der WAV (Rueckgabe von sc_embed_wav), z.B. "
|
|
"'staredit\\\\wav\\\\funk1.wav'.",
|
|
)
|
|
duration_ms: Optional[int] = Field(
|
|
default=None, description="Dauer der WAV in Millisekunden (optional)."
|
|
)
|
|
player: Optional[str] = Field(
|
|
default=None, description="Betroffener Spieler/Gruppe (Quelle bei give_units)."
|
|
)
|
|
target_player: Optional[str] = Field(
|
|
default=None, description="Zielspieler bei give_units."
|
|
)
|
|
amount: int = Field(
|
|
default=1, description="Anzahl Einheiten / Menge. 0 bedeutet 'Alle'."
|
|
)
|
|
unit: Optional[str] = Field(default=None, description="Einheit, z.B. TERRAN_MARINE.")
|
|
location: Optional[str] = Field(
|
|
default=None, description="Name einer existierenden Location."
|
|
)
|
|
destination: Optional[str] = Field(
|
|
default=None, description="Ziel-Location (bei move_unit)."
|
|
)
|
|
seconds: Optional[int] = Field(default=None, description="Sekunden (Timer).")
|
|
modifier: str = Field(
|
|
default="SET_TO", description="Mengen-Modifikator: SET_TO, ADD oder SUBTRACT."
|
|
)
|
|
resource: Optional[str] = Field(
|
|
default=None, description="Ressource: ORE, GAS oder ORE_AND_GAS."
|
|
)
|
|
ai_script: Optional[str] = Field(
|
|
default=None,
|
|
description="AI-Skript: 4-Zeichen-Code (z.B. 'TMCx') oder bekannter Name "
|
|
"(z.B. JUNKYARD_DOG).",
|
|
)
|
|
alliance_status: Optional[str] = Field(
|
|
default=None, description="Allianz: ENEMY, ALLY oder ALLIED_VICTORY."
|
|
)
|
|
|
|
|
|
def _require(value, field: str, action_or_cond_type: str):
|
|
if value is None:
|
|
raise ValueError(
|
|
f"Feld '{field}' fehlt fuer Typ '{action_or_cond_type}'. "
|
|
f"Bitte '{field}' angeben."
|
|
)
|
|
return value
|
|
|
|
|
|
def _resolve_ai_script(value: str) -> AiScript:
|
|
"""Bekannter Name (JUNKYARD_DOG) oder roher 4-Zeichen-Code."""
|
|
key = value.strip()
|
|
try:
|
|
return KnownAiScript[key.upper()].value
|
|
except KeyError:
|
|
pass
|
|
if len(key) == 4:
|
|
return AiScript(key, key)
|
|
known = ", ".join(sorted(k.name for k in KnownAiScript))
|
|
raise ValueError(
|
|
f"Unbekanntes AI-Skript {value!r}. Gib einen 4-Zeichen-Code an "
|
|
f"(z.B. 'TMCx') oder einen bekannten Namen: {known}"
|
|
)
|
|
|
|
|
|
def build_condition(cond: Condition, resolve_location: LocationResolver):
|
|
"""Uebersetze eine Condition-Eingabe in ein RichChk-Conditionobjekt."""
|
|
t = cond.type.strip().lower()
|
|
if t == "always":
|
|
return AlwaysCondition()
|
|
if t == "never":
|
|
return NeverCondition()
|
|
if t == "elapsed_time":
|
|
return ElapsedTimeCondition(
|
|
_seconds=_require(cond.seconds, "seconds", t),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
)
|
|
if t == "countdown_timer":
|
|
return CountdownTimerCondition(
|
|
_seconds=_require(cond.seconds, "seconds", t),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
)
|
|
if t == "bring":
|
|
return BringCondition(
|
|
_group=enums.resolve_player(_require(cond.player, "player", t)),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
_amount=_require(cond.amount, "amount", t),
|
|
_unit=enums.resolve_unit(_require(cond.unit, "unit", t)),
|
|
_location=resolve_location(_require(cond.location, "location", t)),
|
|
)
|
|
if t == "command":
|
|
return CommandCondition(
|
|
_group=enums.resolve_player(_require(cond.player, "player", t)),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
_amount=_require(cond.amount, "amount", t),
|
|
_unit=enums.resolve_unit(_require(cond.unit, "unit", t)),
|
|
)
|
|
if t == "kill":
|
|
return KillCondition(
|
|
_group=enums.resolve_player(_require(cond.player, "player", t)),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
_amount=_require(cond.amount, "amount", t),
|
|
_unit=enums.resolve_unit(_require(cond.unit, "unit", t)),
|
|
)
|
|
if t == "deaths":
|
|
return DeathsCondition(
|
|
_group=enums.resolve_player(_require(cond.player, "player", t)),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
_amount=_require(cond.amount, "amount", t),
|
|
_unit=enums.resolve_unit(_require(cond.unit, "unit", t)),
|
|
)
|
|
if t == "accumulate":
|
|
return AccumulateResourcesCondition(
|
|
_group=enums.resolve_player(_require(cond.player, "player", t)),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
_amount=_require(cond.amount, "amount", t),
|
|
_resource=enums.resolve_resource(_require(cond.resource, "resource", t)),
|
|
)
|
|
if t == "opponents":
|
|
return OpponentsRemainingCondition(
|
|
_group=enums.resolve_player(_require(cond.player, "player", t)),
|
|
_amount=_require(cond.amount, "amount", t),
|
|
_comparator=enums.resolve_comparator(cond.comparator),
|
|
)
|
|
raise ValueError(
|
|
f"Unbekannter Bedingungstyp: {cond.type!r}. Erlaubt: always, never, "
|
|
"elapsed_time, countdown_timer, bring, command, kill, deaths, accumulate, "
|
|
"opponents."
|
|
)
|
|
|
|
|
|
def build_action(act: Action, resolve_location: LocationResolver):
|
|
"""Uebersetze eine Action-Eingabe in ein RichChk-Actionobjekt."""
|
|
t = act.type.strip().lower()
|
|
if t == "display_text":
|
|
return DisplayTextMessageAction(RichString(_require(act.text, "text", t)))
|
|
if t == "set_mission_objectives":
|
|
return SetMissionObjectivesAction(RichString(_require(act.text, "text", t)))
|
|
if t == "play_wav":
|
|
return PlayWavAction(
|
|
_path_to_wav_in_mpq=_require(act.wav_path, "wav_path", t),
|
|
_duration_ms=act.duration_ms,
|
|
)
|
|
if t == "create_unit":
|
|
return CreateUnitAction(
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
act.amount,
|
|
enums.resolve_unit(_require(act.unit, "unit", t)),
|
|
resolve_location(_require(act.location, "location", t)),
|
|
)
|
|
if t == "kill_unit_at_location":
|
|
return KillUnitAtLocationAction(
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
act.amount,
|
|
enums.resolve_unit(_require(act.unit, "unit", t)),
|
|
resolve_location(_require(act.location, "location", t)),
|
|
)
|
|
if t == "remove_unit_at_location":
|
|
return RemoveUnitAtLocationAction(
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
act.amount,
|
|
enums.resolve_unit(_require(act.unit, "unit", t)),
|
|
resolve_location(_require(act.location, "location", t)),
|
|
)
|
|
if t == "move_unit":
|
|
return MoveUnitAction(
|
|
enums.resolve_unit(_require(act.unit, "unit", t)),
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
act.amount,
|
|
resolve_location(_require(act.location, "location", t)),
|
|
resolve_location(_require(act.destination, "destination", t)),
|
|
)
|
|
if t == "give_units":
|
|
return GiveUnitAction(
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
enums.resolve_player(_require(act.target_player, "target_player", t)),
|
|
act.amount,
|
|
enums.resolve_unit(_require(act.unit, "unit", t)),
|
|
resolve_location(_require(act.location, "location", t)),
|
|
)
|
|
if t == "set_resources":
|
|
return SetResourcesAction(
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
enums.resolve_modifier(act.modifier),
|
|
_require(act.amount, "amount", t),
|
|
enums.resolve_resource(_require(act.resource, "resource", t)),
|
|
)
|
|
if t == "set_deaths":
|
|
return SetDeathsAction(
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
enums.resolve_unit(_require(act.unit, "unit", t)),
|
|
_require(act.amount, "amount", t),
|
|
enums.resolve_modifier(act.modifier),
|
|
)
|
|
if t == "set_countdown_timer":
|
|
return SetCountdownTimerAction(
|
|
_require(act.seconds, "seconds", t),
|
|
enums.resolve_modifier(act.modifier),
|
|
)
|
|
if t == "pause_timer":
|
|
return PauseCountdownTimerAction()
|
|
if t == "unpause_timer":
|
|
return UnpauseCountdownTimerAction()
|
|
if t == "run_ai_script":
|
|
return RunAiScriptAction(_resolve_ai_script(_require(act.ai_script, "ai_script", t)))
|
|
if t == "run_ai_script_at_location":
|
|
return RunAiScriptAtLocationAction(
|
|
_resolve_ai_script(_require(act.ai_script, "ai_script", t)),
|
|
resolve_location(_require(act.location, "location", t)),
|
|
)
|
|
if t == "center_view":
|
|
return CenterViewAction(resolve_location(_require(act.location, "location", t)))
|
|
if t == "minimap_ping":
|
|
return MinimapPingAction(resolve_location(_require(act.location, "location", t)))
|
|
if t == "set_alliance_status":
|
|
return SetAllianceStatusAction(
|
|
enums.resolve_player(_require(act.player, "player", t)),
|
|
enums.resolve_alliance(_require(act.alliance_status, "alliance_status", t)),
|
|
)
|
|
if t == "victory":
|
|
return VictoryAction()
|
|
if t == "defeat":
|
|
return DefeatAction()
|
|
raise ValueError(
|
|
f"Unbekannter Aktionstyp: {act.type!r}. Siehe Tool-Beschreibung fuer die Liste."
|
|
)
|
|
|
|
|
|
# --- Klartext-Zusammenfassung fuer describe_map / list_triggers ----------------
|
|
|
|
|
|
def summarize_condition(cond) -> str:
|
|
name = type(cond).__name__.replace("Condition", "")
|
|
parts = [name]
|
|
for attr, label in (
|
|
("group", "player"),
|
|
("comparator", "cmp"),
|
|
("amount", "n"),
|
|
("unit", "unit"),
|
|
("seconds", "s"),
|
|
("resource", "res"),
|
|
("location", "loc"),
|
|
):
|
|
if hasattr(cond, attr):
|
|
val = getattr(cond, attr)
|
|
parts.append(f"{label}={_pretty(val)}")
|
|
return " ".join(parts)
|
|
|
|
|
|
def summarize_action(act) -> str:
|
|
name = type(act).__name__.replace("Action", "")
|
|
parts = [name]
|
|
for attr, label in (
|
|
("group", "player"),
|
|
("from_group", "from"),
|
|
("to_group", "to"),
|
|
("amount", "n"),
|
|
("unit", "unit"),
|
|
("location", "loc"),
|
|
("destination_location", "dst"),
|
|
("message", "text"),
|
|
("path_to_wav_in_mpq", "wav"),
|
|
("seconds", "s"),
|
|
("resource", "res"),
|
|
("alliance_status", "ally"),
|
|
):
|
|
if hasattr(act, attr):
|
|
parts.append(f"{label}={_pretty(getattr(act, attr))}")
|
|
return " ".join(parts)
|
|
|
|
|
|
def _pretty(val) -> str:
|
|
if isinstance(val, RichString):
|
|
return repr(val.value)
|
|
if isinstance(val, RichLocation):
|
|
return repr(val._custom_location_name.value or f"#{val.index}")
|
|
if hasattr(val, "name"):
|
|
try:
|
|
return str(val.name)
|
|
except Exception:
|
|
pass
|
|
return str(val)
|