Skip to content

Commit 41bddda

Browse files
committed
Release: playback real-stats, BAR launch, deploy config
2 parents 4b317ee + 6772048 commit 41bddda

21 files changed

Lines changed: 2928 additions & 88 deletions

.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/

CLAUDE.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What this is
6+
7+
Text prompt → Beyond All Reason (BAR) minigame config (JSON) generator. A natural-language
8+
query is matched against a database of existing game scenarios, then a final scenario config is
9+
synthesized and visualized in the browser. The README is in Korean and is the canonical design doc.
10+
11+
## Commands
12+
13+
Backend (FastAPI, Python 3.11):
14+
```bash
15+
cd backend
16+
pip install -r requirements.txt
17+
cp .env.example .env # set OPENAI_API_KEY (BASE_URL optional)
18+
uvicorn app:app --reload # http://localhost:8000
19+
python pipeline.py "<prompt>" # run the full pipeline from the CLI, no server
20+
python db_call.py # smoke-test DB folder/file LLM routing
21+
```
22+
23+
Frontend (React + Vite, requires Node 20):
24+
```bash
25+
cd frontend
26+
npm install
27+
npm run dev # http://localhost:5173, proxies /api -> :8000
28+
npm run build # production build (this is what CI runs)
29+
```
30+
31+
There is no test suite. CI (`.github/workflows/ci.yml`, on push/PR to `main`/`dev`) only does
32+
`npm run build` for the frontend and, for the backend, `python -m compileall` + `import app`
33+
with a dummy `OPENAI_API_KEY`. **Module import must stay side-effect-free** (no LLM client built
34+
at import time) or CI breaks — clients are constructed lazily inside functions/constructors.
35+
36+
## Architecture
37+
38+
The core design decision: **do not generate a GDD or new rules from scratch.** Find the most
39+
similar existing scenario in `backend/db/`, reuse its validated rules, and only synthesize the
40+
final script. An earlier multi-agent build with an analyst/verify loop was deliberately stripped
41+
to remove the `game_simulation` (BAR engine) and `psutil` runtime dependencies — do not
42+
reintroduce them.
43+
44+
### Backend pipeline (`backend/pipeline.py`, `generate()`) — 3 steps
45+
46+
1. **`find_scenario(query)`** — match the prompt to a scenario in `db/scenario/meta.json`.
47+
Resilient by design: LLM match (`DBCall`) → keyword overlap fallback (`_keyword_fallback`) →
48+
default to the first scenario. Returns `None` only if the scenario DB is unreadable.
49+
2. **`load_existing_mode(name)`** — build a `gdd` dict from the scenario's `specification` and its
50+
referenced rules. Every rule is loaded as `action: existing, validated: True` (never generated).
51+
Rule `.lua` files are copied into the per-session `log/<timestamp>/.../rules_temp/`.
52+
3. **`ScriptDeveloperAgent.run()`** (`script_builder.py`) — a linear LangGraph:
53+
`select_map → place_units → generate_rule_config → get_condition → assemble_draft`. Each node
54+
is one LLM call (prompts in `developer_prompt.py`); `select_map`/`place_units` loop, issuing
55+
`call_db` actions to fetch more context, until the LLM returns `finish`.
56+
57+
`generate()` returns `{scenario, config, raw}` where `config` is the `"normal"` difficulty entry of
58+
`final_json`. The agent always produces exactly the `["normal"]` difficulty.
59+
60+
### DBCall (`backend/db_call.py`) — two-stage LLM retrieval
61+
62+
`db/meta.json` lists folders (`scenario`, `map`, `unit`, `rule`, `decision`, `verify`, `rubric`);
63+
each folder's `meta.json` has a `details` map of `{name: {description, file}}`. `DBCall.call(query)`
64+
asks the LLM to pick a folder, then which files within it — returning file paths relative to `db/`.
65+
Pass `folder=` to skip folder selection. `call_with_names()` also returns the matched DB key names
66+
(used by `find_scenario`).
67+
68+
### API (`backend/app.py`)
69+
70+
- `POST /generate {query, seed?}` → pipeline result. `seed` is threaded down to every `ChatOpenAI`
71+
for reproducibility.
72+
- `GET /catalog` → scenarios (from `db/scenario/meta.json`) + maps (from `info/map.json`).
73+
- `GET /health`. CORS is wide open (`*`).
74+
- The top-level `try/except` returns `{error, config: None}` rather than raising.
75+
76+
### Frontend (`frontend/src/`)
77+
78+
`App.jsx` loads `/catalog` on mount and calls `/generate` via `api.js`. Components: `PromptInput`
79+
(prompt + example chips, ⌘/Ctrl+Enter to submit), `ConfigSummary`, `MiniMap` (2D canvas of
80+
`unit_placement`), `SimPlayback`, `JsonView`. No state library, no router.
81+
82+
## Key conventions
83+
84+
- **LLM model** is hardcoded to `gpt-5.2` in `common.get_client()`. Reads `OPENAI_API_KEY` and
85+
optional `BASE_URL` from env. All token usage flows through the shared `TokenTracker` callback.
86+
- **Coordinate system:** maps are sized in tiles where **1 tile = 512px**. A 16×16 map → 8192×8192
87+
pixels. Units are placed in pixel coordinates. Prompts and `MiniMap` both rely on this.
88+
- **Config shape** (`assemble_draft` output): `{information, end_condition, unit_placement, customize}`.
89+
`unit_placement` is keyed by team (`"1"`, `"2"`) → list of `[unit_code, [x, y]]`. `customize` holds
90+
rule/gadget config (e.g. `enemy_wave_spawner`).
91+
- **`backend/db/`** is the source of truth (scenarios, validated rules + their `.lua` codes, maps,
92+
units, decisions). **`backend/info/`** holds flat unit/map/gadget summaries fed into prompts
93+
(`units_info.json`, `map.json`, etc.) — distinct from `db/`.
94+
- **Logs:** every pipeline run writes to `backend/log/<timestamp>/` (gitignored). Each agent node
95+
dumps its raw result via `common.log_node_result`.
96+
- **Name matching is fuzzy on purpose:** `pipeline._resolve_db_name` / `_normalize_name` reconcile
97+
LLM-returned names against DB keys (case, spaces, parentheses, hyphens). Keep this when LLM output
98+
may not match DB keys exactly.
99+
100+
## Git workflow
101+
102+
`main → dev → feature/*`. Branch `feature/*` off `dev`; PR `feature/* → dev`; release via
103+
`dev → main`. PRs close issues with `Closes #<n>`. Project docs live in the GitHub Wiki (`wiki/`).

DEPLOY.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# 배포 가이드 (무료: Vercel + Render)
2+
3+
프론트엔드(정적 SPA)는 Vercel에, 백엔드(FastAPI)는 Render에 올리는 무료 구성입니다.
4+
5+
> ⚠️ **"BAR에서 실행"(`/launch`)은 배포본에서 동작하지 않습니다.** 그 기능은 백엔드가 도는
6+
> 머신의 데스크톱에서 `spring.exe`를 띄우는 것이라, 화면도 BAR도 없는 클라우드 서버에선
7+
> 불가능합니다. 배포본에서는 자동으로 "launcher not found"로 안전하게 실패하고, 브라우저
8+
> 근사 플레이백(SimPlayback)은 정상 동작합니다.
9+
>
10+
> ⚠️ **호스팅은 무료지만 LLM 호출은 본인 OpenAI 키로 과금됩니다.** (토큰만큼)
11+
12+
## 1. 백엔드 — Render (무료 Web Service)
13+
14+
1. Render Dashboard → **New → Blueprint** → 이 저장소 선택 → 루트의 [`render.yaml`](render.yaml) 감지.
15+
2. 환경변수 `OPENAI_API_KEY` 를 Render 대시보드에서 입력 (절대 커밋 금지).
16+
3. 배포 후 URL 확인 (예: `https://rtsgame-backend.onrender.com`).
17+
18+
- 무료 티어는 15분 무활동 시 **슬립** → 첫 요청에 1분+ 콜드스타트. 발표 직전 `/health` 한 번
19+
호출해 깨워두거나 외부 cron으로 5~10분마다 ping.
20+
- LLM 응답이 수십 초~2분 걸리므로 **서버리스(Vercel/Netlify Functions)에는 백엔드를 두면 안 됩니다**
21+
(짧은 타임아웃에 끊김). Render 같은 상시 컨테이너여야 합니다.
22+
23+
## 2. 프론트엔드 — Vercel (무료)
24+
25+
1. Vercel → **New Project** → 이 저장소 선택.
26+
2. **Root Directory = `frontend`** 로 지정 ([`frontend/vercel.json`](frontend/vercel.json)이 빌드/SPA 라우팅 처리).
27+
3. 환경변수 **`VITE_API_BASE`** = 위 Render 백엔드 URL (예: `https://rtsgame-backend.onrender.com`).
28+
4. 배포.
29+
30+
`VITE_API_BASE`가 없으면(로컬 dev) 자동으로 Vite 프록시(`/api``localhost:8000`)를 씁니다.
31+
프로덕션에서만 이 변수로 실제 백엔드를 가리킵니다. — [frontend/src/api.js](frontend/src/api.js)
32+
33+
## 3. 확인
34+
35+
- 백엔드: `https://<render-url>/health``{"status":"ok"}`
36+
- 프론트: Vercel URL 접속 → 프롬프트 생성 → 결과/플레이백 표시.
37+
(첫 생성은 Render 콜드스타트로 느릴 수 있음.)

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/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Copy to .env.local for local overrides, or set in the Vercel project settings.
2+
# Leave UNSET in local dev so calls go through the Vite proxy (/api).
3+
# In production, point this at the deployed backend origin:
4+
VITE_API_BASE=https://rtsgame-backend.onrender.com

0 commit comments

Comments
 (0)