* Projekt-Spezifikation für StarCraft-Kampagnen-MCP-Server in README festhalten Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NaZBZofC1on2gkSMb5UWZy * StarCraft-Kampagnen-MCP-Server: vollstaendige Implementierung 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
155 lines
5.6 KiB
Python
Executable file
155 lines
5.6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Selbsttest: beweist die komplette Pipeline durch die echten MCP-Tools.
|
|
|
|
Ablauf (Definition of Done):
|
|
1. Basis-Karte aus dem Karten-Verzeichnis laden.
|
|
2. Eine Location anlegen.
|
|
3. Trigger hinzufuegen: bei Spielstart Text "Mission 1 - Start" einblenden UND
|
|
ein paar Einheiten an der Location erzeugen.
|
|
4. Als neue .scx speichern.
|
|
5. Neue Datei erneut laden und bestaetigen, dass Trigger + Location drin sind.
|
|
|
|
Start: python selftest.py [basis-karte.scx]
|
|
Exit-Code 0 = bestanden.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
|
|
|
|
def _data(result) -> dict:
|
|
"""Hole das strukturierte Ergebnis aus einem FastMCP call_tool-Resultat."""
|
|
# FastMCP gibt (content_blocks, structured_result) zurueck.
|
|
if isinstance(result, tuple) and len(result) == 2:
|
|
structured = result[1]
|
|
if isinstance(structured, dict):
|
|
return structured
|
|
# Fallback: erstes TextContent als JSON parsen.
|
|
blocks = result[0] if isinstance(result, tuple) else result
|
|
for b in blocks:
|
|
text = getattr(b, "text", None)
|
|
if text:
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return {"text": text}
|
|
return {}
|
|
|
|
|
|
async def run(base_map: str) -> bool:
|
|
workdir = tempfile.mkdtemp(prefix="sc_selftest_")
|
|
os.environ["SC_MAPS_DIR"] = workdir
|
|
shutil.copyfile(base_map, os.path.join(workdir, os.path.basename(base_map)))
|
|
base_name = os.path.basename(base_map)
|
|
|
|
# Workspace-Modul liest SC_MAPS_DIR beim Import -> erst jetzt importieren.
|
|
from starcraft_mcp import workspace # noqa: E402
|
|
from starcraft_mcp.server import mcp # noqa: E402
|
|
|
|
workspace.MAPS_DIR = workdir # falls Modul bereits importiert war
|
|
|
|
print(f"[1] Basis-Karte: {base_name} (Verzeichnis: {workdir})")
|
|
res = _data(await mcp.call_tool("sc_list_maps", {}))
|
|
assert base_name in res["maps"], f"Basis-Karte nicht gelistet: {res}"
|
|
|
|
print("[2] Location 'Basis' anlegen ...")
|
|
res = _data(
|
|
await mcp.call_tool(
|
|
"sc_create_location",
|
|
{"map": base_name, "name": "Basis", "center_x": 512, "center_y": 512},
|
|
)
|
|
)
|
|
assert res.get("ok"), f"create_location fehlgeschlagen: {res}"
|
|
print(f" -> Index {res['index']}, Box {res['box']}")
|
|
|
|
print("[3] Trigger hinzufuegen (Text + 4 Marines bei Spielstart) ...")
|
|
res = _data(
|
|
await mcp.call_tool(
|
|
"sc_add_trigger",
|
|
{
|
|
"map": base_name,
|
|
"players": ["PLAYER_1"],
|
|
"conditions": [{"type": "always"}],
|
|
"actions": [
|
|
{"type": "display_text", "text": "Mission 1 - Start"},
|
|
{
|
|
"type": "create_unit",
|
|
"player": "PLAYER_1",
|
|
"amount": 4,
|
|
"unit": "TERRAN_MARINE",
|
|
"location": "Basis",
|
|
},
|
|
],
|
|
},
|
|
)
|
|
)
|
|
assert res.get("ok"), f"add_trigger fehlgeschlagen: {res}"
|
|
print(f" -> {res['summary']}")
|
|
|
|
print("[4] Als 'mission1.scx' speichern ...")
|
|
res = _data(
|
|
await mcp.call_tool(
|
|
"sc_save_map",
|
|
{"map": base_name, "output_name": "mission1.scx", "overwrite": True},
|
|
)
|
|
)
|
|
assert res.get("ok"), f"save_map fehlgeschlagen: {res}"
|
|
out_path = res["path"]
|
|
print(f" -> {out_path} ({os.path.getsize(out_path)} Bytes)")
|
|
|
|
print("[5] Neue Datei unabhaengig neu laden und verifizieren ...")
|
|
from richchk.io.mpq.starcraft_mpq_io_helper import StarCraftMpqIoHelper
|
|
from richchk.io.richchk.query.chk_query_util import ChkQueryUtil
|
|
from richchk.model.richchk.mrgn.rich_mrgn_section import RichMrgnSection
|
|
from richchk.model.richchk.trig.rich_trig_section import RichTrigSection
|
|
|
|
chk = StarCraftMpqIoHelper.create_mpq_io().read_chk_from_mpq(out_path)
|
|
mrgn = ChkQueryUtil.find_only_rich_section_in_chk(RichMrgnSection, chk)
|
|
trig = ChkQueryUtil.find_only_rich_section_in_chk(RichTrigSection, chk)
|
|
loc_names = [l._custom_location_name.value for l in mrgn.locations]
|
|
assert "Basis" in loc_names, f"Location 'Basis' fehlt nach Reload: {loc_names}"
|
|
assert len(trig.triggers) >= 1, "Kein Trigger nach Reload gefunden"
|
|
action_types = [type(a).__name__ for a in trig.triggers[-1].actions]
|
|
assert "DisplayTextMessageAction" in action_types, action_types
|
|
assert "CreateUnitAction" in action_types, action_types
|
|
print(" -> Location 'Basis' vorhanden: True")
|
|
print(f" -> Trigger-Anzahl: {len(trig.triggers)}")
|
|
print(" -> Aktionen im Trigger: DisplayText + CreateUnit bestaetigt")
|
|
|
|
shutil.rmtree(workdir, ignore_errors=True)
|
|
return True
|
|
|
|
|
|
def _find_default_base_map() -> str:
|
|
here = os.path.dirname(os.path.abspath(__file__))
|
|
candidate = os.path.join(here, "data", "maps", "base-map.scx")
|
|
return candidate
|
|
|
|
|
|
def main() -> int:
|
|
base_map = sys.argv[1] if len(sys.argv) > 1 else _find_default_base_map()
|
|
if not os.path.exists(base_map):
|
|
print(f"FEHLER: Basis-Karte nicht gefunden: {base_map}")
|
|
return 2
|
|
try:
|
|
ok = asyncio.run(run(base_map))
|
|
except AssertionError as exc:
|
|
print(f"\nSELBSTTEST FEHLGESCHLAGEN: {exc}")
|
|
return 1
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"\nSELBSTTEST FEHLER: {type(exc).__name__}: {exc}")
|
|
return 1
|
|
if ok:
|
|
print("\n=== SELBSTTEST BESTANDEN ===")
|
|
return 0
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|