Skip to content

Commit 3ceabd3

Browse files
committed
Merge feature/bar-launch into dev
2 parents 2370232 + 380fe1f commit 3ceabd3

10 files changed

Lines changed: 240 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ jobs:
3737
- uses: actions/setup-python@v5
3838
with:
3939
python-version: '3.11'
40-
- run: pip install -r requirements.txt
40+
- run: pip install -r requirements-dev.txt
4141
- name: Compile all modules
4242
run: python -m compileall -q .
4343
- name: Import API app
4444
run: python -c "import app; print('app import OK')"
45+
- name: Run unit tests
46+
run: python -m pytest -q

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ dist/
2626

2727
# backend runtime logs
2828
backend/log/
29+
30+
# Bundled BAR launcher reference project — NOT part of this repo.
31+
# Contains a real API key (.env), a venv, game data and demos. The /launch
32+
# endpoint uses it locally if present and degrades gracefully if absent.
33+
minigame_generator_v4/

backend/app.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,21 @@
88
"""
99

1010
import json
11+
import os
12+
import sys
13+
import subprocess
1114
from pathlib import Path
1215

16+
# Windows consoles default to cp949 in a Korean locale; the pipeline's progress
17+
# logs contain emoji (🔎, ⚠️, …). Without this, the first such print() raises
18+
# UnicodeEncodeError and bubbles up as a /generate failure. Force UTF-8 so logs
19+
# never crash a request.
20+
for _stream in (sys.stdout, sys.stderr):
21+
try:
22+
_stream.reconfigure(encoding="utf-8")
23+
except Exception:
24+
pass
25+
1326
from fastapi import FastAPI
1427
from fastapi.middleware.cors import CORSMiddleware
1528
from pydantic import BaseModel
@@ -19,6 +32,15 @@
1932
CURRENT_DIR = Path(__file__).resolve().parent
2033
DB_DIR = CURRENT_DIR / "db"
2134
INFO_DIR = CURRENT_DIR / "info"
35+
RULE_CODES_DIR = DB_DIR / "rule" / "codes"
36+
37+
# The full BAR launcher lives in the bundled v4 project. Its game_simulator
38+
# converts a config (same JSON shape this backend emits) into a Spring start
39+
# script and launches spring.exe. It reads its own .env (SPRING_DATADIR,
40+
# BAR_ENGINE_PATH, GAME_VERSION) via load_dotenv inside main.py.
41+
V4_DIR = CURRENT_DIR.parent / "minigame_generator_v4"
42+
SIM_DIR = V4_DIR / "game_simulator"
43+
SIM_MAIN = SIM_DIR / "main.py"
2244

2345
app = FastAPI(title="RTSGame Minigame Generator", version="0.1.0")
2446

@@ -79,6 +101,77 @@ def generate(req: GenerateRequest):
79101
return {"error": str(e), "config": None}
80102

81103

104+
class LaunchRequest(BaseModel):
105+
config: dict
106+
# "gadget" = run the scenario's own gadgets only (no movement/vlm AI);
107+
# "movement"/"vlm" enable the v4 experiment gadgets.
108+
mode: str = "gadget"
109+
110+
111+
def _resolve_gadgets(config: dict) -> list:
112+
"""Each customize key corresponds to a rule gadget whose Lua lives at
113+
db/rule/codes/<key>.lua. The launcher copies these into the engine and
114+
enables them; the per-key config is delivered via the scenariooptions blob.
115+
Missing files are skipped (the scenario still launches without them)."""
116+
gadgets = []
117+
for key in (config.get("customize") or {}):
118+
lua = RULE_CODES_DIR / f"{key}.lua"
119+
if lua.exists():
120+
gadgets.append(str(lua))
121+
return gadgets
122+
123+
124+
@app.post("/launch")
125+
def launch(req: LaunchRequest):
126+
"""Launch the generated config in the actual BAR client via the bundled
127+
v4 game_simulator. Fire-and-forget: spring.exe opens its own window and the
128+
request returns as soon as the process is spawned."""
129+
if not SIM_MAIN.exists():
130+
return {"launched": False, "error": f"BAR launcher not found at {SIM_MAIN}"}
131+
132+
config = req.config or {}
133+
if not config.get("information", {}).get("map_name"):
134+
return {"launched": False, "error": "config has no information.map_name"}
135+
136+
# Prefer the v4 venv interpreter — it has the deps (PIL/numpy/dotenv) that
137+
# game_simulator/utils import; fall back to whatever `python` is on PATH.
138+
venv_py = V4_DIR / ".venv" / "Scripts" / "python.exe"
139+
python_exe = str(venv_py) if venv_py.exists() else "python"
140+
141+
gen_dir = SIM_DIR / "_generated"
142+
gen_dir.mkdir(exist_ok=True)
143+
cfg_path = gen_dir / "launch_config.json"
144+
with open(cfg_path, "w", encoding="utf-8") as f:
145+
json.dump(config, f, ensure_ascii=False, indent=2)
146+
147+
gadgets = _resolve_gadgets(config)
148+
149+
cmd = [python_exe, str(SIM_MAIN), "--config", str(cfg_path), "--mode", req.mode]
150+
if gadgets:
151+
cmd += ["--gadgets"] + gadgets
152+
153+
log_dir = CURRENT_DIR / "log"
154+
log_dir.mkdir(exist_ok=True)
155+
try:
156+
log_fh = open(log_dir / "launch.log", "a", encoding="utf-8")
157+
# New process group so the game survives a uvicorn reload/restart.
158+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
159+
subprocess.Popen(
160+
cmd, cwd=str(SIM_DIR),
161+
stdout=log_fh, stderr=subprocess.STDOUT,
162+
creationflags=creationflags,
163+
)
164+
except Exception as e:
165+
return {"launched": False, "error": str(e)}
166+
167+
return {
168+
"launched": True,
169+
"mode": req.mode,
170+
"map": config["information"]["map_name"],
171+
"gadgets": [Path(g).stem for g in gadgets],
172+
}
173+
174+
82175
if __name__ == "__main__":
83176
import uvicorn
84177
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)

backend/requirements-dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-r requirements.txt
2+
pytest>=8

backend/tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import os
2+
import sys
3+
4+
# Make backend modules (app, pipeline, ...) importable regardless of the cwd
5+
# pytest is launched from.
6+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

backend/tests/test_app.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Unit tests for the pure helpers in app.py (no subprocess / engine launch)."""
2+
from pathlib import Path
3+
4+
import app
5+
6+
7+
def test_resolve_gadgets_maps_customize_keys_to_existing_lua():
8+
# 'prioritize_target' ships as db/rule/codes/prioritize_target.lua;
9+
# the bogus key must be silently skipped, not raise.
10+
cfg = {"customize": {"prioritize_target": {}, "nonexistent_gadget": {}}}
11+
names = [Path(g).name for g in app._resolve_gadgets(cfg)]
12+
assert "prioritize_target.lua" in names
13+
assert all("nonexistent_gadget" not in n for n in names)
14+
15+
16+
def test_resolve_gadgets_handles_empty_or_missing_customize():
17+
assert app._resolve_gadgets({}) == []
18+
assert app._resolve_gadgets({"customize": {}}) == []
19+
assert app._resolve_gadgets({"customize": None}) == []
20+
21+
22+
def test_resolve_gadgets_returns_absolute_existing_paths():
23+
for path in app._resolve_gadgets({"customize": {"prioritize_target": {}}}):
24+
assert Path(path).is_file()
25+
26+
27+
def test_launch_request_defaults_to_gadget_mode():
28+
req = app.LaunchRequest(config={"information": {"map_name": "BarR 1.1"}})
29+
assert req.mode == "gadget"
30+
31+
32+
def test_load_json_returns_default_on_missing_file(tmp_path):
33+
assert app._load_json(tmp_path / "missing.json", {"fallback": True}) == {"fallback": True}

backend/tests/test_pipeline.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Unit tests for the pure (no LLM, no engine) helpers in pipeline.py.
2+
3+
These guard the resilient scenario-matching fallback chain
4+
(LLM match -> keyword overlap -> default) that runs when the DB/LLM layer
5+
returns nothing useful.
6+
"""
7+
import pipeline
8+
9+
10+
def test_normalize_name_strips_parens_and_collapses_separators():
11+
assert pipeline._normalize_name("Multi-Front Defense") == "multi_front_defense"
12+
assert pipeline._normalize_name("Siege Planning (v2)") == "siege_planning"
13+
assert pipeline._normalize_name("time__phased production") == "time_phased_production"
14+
15+
16+
def test_resolve_db_name_exact_match_wins():
17+
candidates = ["Multi-Front Defense", "Siege Planning"]
18+
assert pipeline._resolve_db_name("Multi-Front Defense", candidates) == "Multi-Front Defense"
19+
20+
21+
def test_resolve_db_name_fuzzy_match_on_casing_and_separators():
22+
candidates = ["Multi-Front Defense", "Siege Planning"]
23+
# LLM may return a differently-cased / spaced variant of the DB key.
24+
assert pipeline._resolve_db_name("multi front defense", candidates) == "Multi-Front Defense"
25+
26+
27+
def test_resolve_db_name_unknown_returns_input_unchanged():
28+
candidates = ["Multi-Front Defense"]
29+
assert pipeline._resolve_db_name("Totally Unknown Mode", candidates) == "Totally Unknown Mode"
30+
31+
32+
def test_keyword_fallback_picks_highest_overlap():
33+
details = {
34+
"Multi-Front Defense": {"description": "defend multiple fronts against waves"},
35+
"Siege Planning": {"description": "plan a siege on a fixed base"},
36+
}
37+
assert pipeline._keyword_fallback("defend against waves", details) == "Multi-Front Defense"
38+
39+
40+
def test_keyword_fallback_no_overlap_returns_none():
41+
details = {"Siege Planning": {"description": "plan a siege"}}
42+
assert pipeline._keyword_fallback("xyzzy qwerty", details) is None

frontend/src/App.jsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useState } from 'react'
2-
import { getCatalog, generate } from './api'
2+
import { getCatalog, generate, launch } from './api'
33
import PromptInput from './components/PromptInput'
44
import ConfigSummary from './components/ConfigSummary'
55
import MiniMap from './components/MiniMap'
@@ -25,18 +25,40 @@ export default function App() {
2525
const [error, setError] = useState(null)
2626
const [result, setResult] = useState(null)
2727
const [lastQuery, setLastQuery] = useState('')
28+
const [launching, setLaunching] = useState(false)
29+
const [launchMsg, setLaunchMsg] = useState(null)
2830

2931
useEffect(() => {
3032
getCatalog()
3133
.then(setCatalog)
3234
.catch((e) => console.warn('catalog load failed', e))
3335
}, [])
3436

37+
async function handleLaunch() {
38+
if (!config) return
39+
setLaunching(true)
40+
setLaunchMsg(null)
41+
try {
42+
const res = await launch(config)
43+
if (res.launched) {
44+
const g = res.gadgets?.length ? ` · 룰: ${res.gadgets.join(', ')}` : ''
45+
setLaunchMsg({ ok: true, text: `BAR 실행 요청됨 (맵: ${res.map}${g}). 게임 창이 곧 뜹니다.` })
46+
} else {
47+
setLaunchMsg({ ok: false, text: `실행 실패: ${res.error || '알 수 없는 오류'}` })
48+
}
49+
} catch (e) {
50+
setLaunchMsg({ ok: false, text: `백엔드에 연결하지 못했어요 (${e.message}).` })
51+
} finally {
52+
setLaunching(false)
53+
}
54+
}
55+
3556
async function handleGenerate(query) {
3657
setLastQuery(query)
3758
setLoading(true)
3859
setError(null)
3960
setResult(null)
61+
setLaunchMsg(null)
4062
try {
4163
const res = await generate(query)
4264
if (res.error || !res.config) {
@@ -95,6 +117,19 @@ export default function App() {
95117
<ConfigSummary config={config} scenario={result.scenario} />
96118
<MiniMap config={config} mapMeta={mapMeta} />
97119
</div>
120+
121+
<div className="launch-bar">
122+
<button className="btn primary" onClick={handleLaunch} disabled={launching}>
123+
{launching ? '⏳ 실행 중…' : '🎮 BAR에서 실행'}
124+
</button>
125+
<span className="muted">실제 Beyond All Reason 엔진으로 이 시나리오를 띄웁니다.</span>
126+
{launchMsg && (
127+
<span className={launchMsg.ok ? 'launch-ok' : 'launch-err'}>
128+
{launchMsg.ok ? '✅ ' : '⚠️ '}{launchMsg.text}
129+
</span>
130+
)}
131+
</div>
132+
98133
<SimPlayback config={config} mapMeta={mapMeta} />
99134
<JsonView config={config} scenario={result.scenario} />
100135
</div>

frontend/src/api.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,14 @@ export async function generate(query, seed) {
1616
if (!res.ok) throw new Error(`generate failed: ${res.status}`)
1717
return res.json()
1818
}
19+
20+
// Launch the generated config in the actual BAR client (via the v4 simulator).
21+
export async function launch(config, mode = 'gadget') {
22+
const res = await fetch(`${BASE}/launch`, {
23+
method: 'POST',
24+
headers: { 'Content-Type': 'application/json' },
25+
body: JSON.stringify({ config, mode }),
26+
})
27+
if (!res.ok) throw new Error(`launch failed: ${res.status}`)
28+
return res.json()
29+
}

frontend/src/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,15 @@ body {
166166
.btn:hover { border-color: var(--accent); }
167167
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
168168
.btn.primary:hover { background: var(--accent-hover); }
169+
.btn:disabled { opacity: 0.6; cursor: default; }
170+
.launch-bar {
171+
display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px;
172+
margin: 16px 0; padding: 14px 16px;
173+
background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;
174+
}
175+
.launch-bar .muted { color: var(--muted); font-size: 13px; }
176+
.launch-ok { color: #22c55e; font-size: 13px; }
177+
.launch-err { color: #ef4444; font-size: 13px; }
169178
.json-pre {
170179
margin: 14px 0 0; max-height: 420px; overflow: auto;
171180
background: #0a0f1d; border: 1px solid var(--border); border-radius: 10px;

0 commit comments

Comments
 (0)