feat: initial Lantern Space (agent loop, 3D viewer, live runs)
Browse files- .gitattributes +2 -0
- Dockerfile +21 -0
- README.md +27 -5
- app.py +175 -0
- lantern/__init__.py +7 -0
- lantern/agent.py +221 -0
- lantern/env.py +137 -0
- lantern/journal.py +45 -0
- lantern/llm.py +81 -0
- lantern/render.py +227 -0
- requirements.txt +6 -0
- web/app.js +368 -0
- web/assets/kenney-mini-dungeon/License.txt +28 -0
- web/assets/kenney-mini-dungeon/Textures/colormap.png +0 -0
- web/assets/kenney-mini-dungeon/banner.glb +0 -0
- web/assets/kenney-mini-dungeon/barrel.glb +0 -0
- web/assets/kenney-mini-dungeon/character-human.glb +3 -0
- web/assets/kenney-mini-dungeon/character-orc.glb +3 -0
- web/assets/kenney-mini-dungeon/chest.glb +0 -0
- web/assets/kenney-mini-dungeon/coin.glb +0 -0
- web/assets/kenney-mini-dungeon/column.glb +0 -0
- web/assets/kenney-mini-dungeon/floor-detail.glb +0 -0
- web/assets/kenney-mini-dungeon/floor.glb +0 -0
- web/assets/kenney-mini-dungeon/gate.glb +0 -0
- web/assets/kenney-mini-dungeon/key.glb +0 -0
- web/assets/kenney-mini-dungeon/potion.glb +0 -0
- web/assets/kenney-mini-dungeon/rocks.glb +0 -0
- web/assets/kenney-mini-dungeon/stairs.glb +0 -0
- web/assets/kenney-mini-dungeon/stones.glb +0 -0
- web/assets/kenney-mini-dungeon/trap.glb +0 -0
- web/assets/kenney-mini-dungeon/wall-opening.glb +0 -0
- web/assets/kenney-mini-dungeon/wall.glb +0 -0
- web/index.html +113 -0
- web/scene3d.js +307 -0
- web/style.css +270 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
web/assets/kenney-mini-dungeon/character-human.glb filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
web/assets/kenney-mini-dungeon/character-orc.glb filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Spaces run containers as uid 1000.
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
USER user
|
| 6 |
+
ENV HOME=/home/user \
|
| 7 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 8 |
+
PYTHONUNBUFFERED=1 \
|
| 9 |
+
LANTERN_DATA=/home/user/app/data
|
| 10 |
+
WORKDIR /home/user/app
|
| 11 |
+
|
| 12 |
+
COPY --chown=user requirements.txt .
|
| 13 |
+
RUN pip install --no-cache-dir --user -r requirements.txt
|
| 14 |
+
|
| 15 |
+
COPY --chown=user lantern ./lantern
|
| 16 |
+
COPY --chown=user web ./web
|
| 17 |
+
COPY --chown=user app.py .
|
| 18 |
+
RUN mkdir -p data/episodes
|
| 19 |
+
|
| 20 |
+
EXPOSE 7860
|
| 21 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,32 @@
|
|
| 1 |
---
|
| 2 |
title: Lantern
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Lantern
|
| 3 |
+
emoji: ๐ฎ
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
short_description: Beam explores dungeons, keeps a journal, and learns.
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Lantern
|
| 13 |
+
|
| 14 |
+
Watch [Beam](https://huggingface.co/reflection/Beam-1.0-501B-A23B) explore procedurally
|
| 15 |
+
generated dungeon levels. Each call, Beam reads the map, plans a short sequence of moves,
|
| 16 |
+
keeps working notes, and acts. After every run it writes lessons into a journal that the
|
| 17 |
+
next run on the same level reads first.
|
| 18 |
+
|
| 19 |
+
Work in progress for the Build with Beam challenge. Full write-up, benchmark results, and
|
| 20 |
+
dataset coming before the deadline.
|
| 21 |
+
|
| 22 |
+
## Credits
|
| 23 |
+
|
| 24 |
+
- Levels: [MiniHack](https://github.com/facebookresearch/minihack) (Meta AI, Apache-2.0)
|
| 25 |
+
on the NetHack Learning Environment.
|
| 26 |
+
- 3D models: [Kenney Mini Dungeon](https://kenney.nl/assets/mini-dungeon) (CC0).
|
| 27 |
+
- Model: Beam 1.0 by Reflection, via Hugging Face Inference Providers.
|
| 28 |
+
|
| 29 |
+
## Limitations
|
| 30 |
+
|
| 31 |
+
Beam is an AI model and makes mistakes; these runs are game play only. Live runs are
|
| 32 |
+
limited per day because each one spends inference credits.
|
app.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lantern web server: runs Beam episodes and streams them to the browser over SSE.
|
| 2 |
+
|
| 3 |
+
Live runs spend the Space owner's inference credits, so they are limited to one at a
|
| 4 |
+
time and a daily cap. Every finished episode is saved and can be replayed for free.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
import uuid
|
| 15 |
+
from datetime import date
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from fastapi import FastAPI, HTTPException
|
| 19 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 20 |
+
from fastapi.staticfiles import StaticFiles
|
| 21 |
+
from pydantic import BaseModel, Field
|
| 22 |
+
|
| 23 |
+
from lantern.agent import EpisodeConfig, run_episode
|
| 24 |
+
from lantern.env import TASKS, DungeonEnv
|
| 25 |
+
from lantern.journal import Journal
|
| 26 |
+
from lantern.llm import ChatClient
|
| 27 |
+
|
| 28 |
+
DATA_DIR = Path(os.environ.get("LANTERN_DATA", "data"))
|
| 29 |
+
EPISODES_DIR = DATA_DIR / "episodes"
|
| 30 |
+
DAILY_LIVE_RUNS = int(os.environ.get("LANTERN_DAILY_RUNS", "20"))
|
| 31 |
+
WEB_DIR = Path(__file__).parent / "web"
|
| 32 |
+
|
| 33 |
+
app = FastAPI(title="Lantern")
|
| 34 |
+
journal = Journal(DATA_DIR / "journal.json")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Run:
|
| 38 |
+
"""One live episode running in a background thread, buffering its events."""
|
| 39 |
+
|
| 40 |
+
def __init__(self, task_id: str, seed: int, use_lessons: bool):
|
| 41 |
+
self.id = uuid.uuid4().hex[:12]
|
| 42 |
+
self.task_id, self.seed, self.use_lessons = task_id, seed, use_lessons
|
| 43 |
+
self.events: list[dict] = []
|
| 44 |
+
self.done = False
|
| 45 |
+
self.stop = threading.Event()
|
| 46 |
+
|
| 47 |
+
def start(self) -> None:
|
| 48 |
+
threading.Thread(target=self._play, daemon=True).start()
|
| 49 |
+
|
| 50 |
+
def _play(self) -> None:
|
| 51 |
+
env = None
|
| 52 |
+
try:
|
| 53 |
+
env = DungeonEnv(TASKS[self.task_id], seed=self.seed)
|
| 54 |
+
lessons = journal.lessons(self.task_id)
|
| 55 |
+
config = EpisodeConfig(use_lessons=self.use_lessons)
|
| 56 |
+
for event in run_episode(env, ChatClient(), lessons, config, self.stop):
|
| 57 |
+
if event["type"] == "end" and event["new_lessons"]:
|
| 58 |
+
event["journal"] = journal.add(self.task_id, event["new_lessons"])
|
| 59 |
+
self.events.append(event)
|
| 60 |
+
except Exception as exc: # surface the failure to the viewer instead of hanging
|
| 61 |
+
self.events.append({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
|
| 62 |
+
finally:
|
| 63 |
+
if env is not None:
|
| 64 |
+
env.close()
|
| 65 |
+
self.done = True
|
| 66 |
+
self._save()
|
| 67 |
+
|
| 68 |
+
def _save(self) -> None:
|
| 69 |
+
EPISODES_DIR.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
end = next((e for e in reversed(self.events) if e["type"] == "end"), {})
|
| 71 |
+
record = {"id": self.id, "task": self.task_id, "seed": self.seed,
|
| 72 |
+
"use_lessons": self.use_lessons, "finished_at": time.time(),
|
| 73 |
+
"outcome": end.get("outcome", "error"), "steps": end.get("steps", 0),
|
| 74 |
+
"events": self.events}
|
| 75 |
+
(EPISODES_DIR / f"{self.id}.json").write_text(json.dumps(record), "utf-8")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
runs: dict[str, Run] = {}
|
| 79 |
+
_quota = {"day": date.today(), "used": 0}
|
| 80 |
+
_lock = threading.Lock()
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class RunRequest(BaseModel):
|
| 84 |
+
task: str
|
| 85 |
+
seed: int = Field(default=0, ge=0, le=1_000_000)
|
| 86 |
+
use_lessons: bool = True
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.get("/api/tasks")
|
| 90 |
+
def list_tasks() -> list[dict]:
|
| 91 |
+
return [{"id": t.id, "title": t.title, "goal": t.goal, "max_steps": t.max_steps,
|
| 92 |
+
"lessons": len(journal.lessons(t.id))} for t in TASKS.values()]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.get("/api/status")
|
| 96 |
+
def status() -> dict:
|
| 97 |
+
busy = any(not r.done for r in runs.values())
|
| 98 |
+
return {"busy": busy, "runs_left_today": max(0, DAILY_LIVE_RUNS - _quota["used"])}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.post("/api/runs")
|
| 102 |
+
def start_run(req: RunRequest) -> dict:
|
| 103 |
+
if req.task not in TASKS:
|
| 104 |
+
raise HTTPException(404, f"Unknown level '{req.task}'.")
|
| 105 |
+
with _lock:
|
| 106 |
+
if _quota["day"] != date.today():
|
| 107 |
+
_quota.update(day=date.today(), used=0)
|
| 108 |
+
if any(not r.done for r in runs.values()):
|
| 109 |
+
raise HTTPException(409, "Beam is already exploring. Watch that run or open a replay.")
|
| 110 |
+
if _quota["used"] >= DAILY_LIVE_RUNS:
|
| 111 |
+
raise HTTPException(429, "Today's live runs are used up. Replays are still available.")
|
| 112 |
+
_quota["used"] += 1
|
| 113 |
+
run = Run(req.task, req.seed, req.use_lessons)
|
| 114 |
+
runs[run.id] = run
|
| 115 |
+
run.start()
|
| 116 |
+
return {"id": run.id}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@app.post("/api/runs/{run_id}/stop")
|
| 120 |
+
def stop_run(run_id: str) -> dict:
|
| 121 |
+
run = runs.get(run_id)
|
| 122 |
+
if run is None:
|
| 123 |
+
raise HTTPException(404, "No such run.")
|
| 124 |
+
run.stop.set()
|
| 125 |
+
return {"stopping": True}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.get("/api/runs/{run_id}/events")
|
| 129 |
+
async def run_events(run_id: str) -> StreamingResponse:
|
| 130 |
+
run = runs.get(run_id)
|
| 131 |
+
if run is None:
|
| 132 |
+
raise HTTPException(404, "No such run.")
|
| 133 |
+
|
| 134 |
+
async def stream():
|
| 135 |
+
sent = 0
|
| 136 |
+
while True:
|
| 137 |
+
while sent < len(run.events):
|
| 138 |
+
yield f"data: {json.dumps(run.events[sent])}\n\n"
|
| 139 |
+
sent += 1
|
| 140 |
+
if run.done and sent >= len(run.events):
|
| 141 |
+
yield "event: close\ndata: {}\n\n"
|
| 142 |
+
return
|
| 143 |
+
await asyncio.sleep(0.25)
|
| 144 |
+
|
| 145 |
+
return StreamingResponse(stream(), media_type="text/event-stream",
|
| 146 |
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@app.get("/api/episodes")
|
| 150 |
+
def list_episodes() -> list[dict]:
|
| 151 |
+
if not EPISODES_DIR.exists():
|
| 152 |
+
return []
|
| 153 |
+
out = []
|
| 154 |
+
for path in sorted(EPISODES_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)[:50]:
|
| 155 |
+
rec = json.loads(path.read_text("utf-8"))
|
| 156 |
+
out.append({k: rec[k] for k in ("id", "task", "seed", "use_lessons", "outcome", "steps", "finished_at")})
|
| 157 |
+
return out
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@app.get("/api/episodes/{episode_id}")
|
| 161 |
+
def get_episode(episode_id: str) -> dict:
|
| 162 |
+
if not episode_id.isalnum():
|
| 163 |
+
raise HTTPException(400, "Bad episode id.")
|
| 164 |
+
path = EPISODES_DIR / f"{episode_id}.json"
|
| 165 |
+
if not path.exists():
|
| 166 |
+
raise HTTPException(404, "No such episode.")
|
| 167 |
+
return json.loads(path.read_text("utf-8"))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@app.get("/")
|
| 171 |
+
def index() -> FileResponse:
|
| 172 |
+
return FileResponse(WEB_DIR / "index.html")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
|
lantern/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lantern: Beam explores procedurally generated dungeons, learns from its own lessons
|
| 2 |
+
journal, and distills what it learns into a small student model.
|
| 3 |
+
|
| 4 |
+
Dungeon levels come from the MiniHack library (Meta AI, Apache-2.0).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
__version__ = "0.1.0"
|
lantern/agent.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Lantern agent loop: Beam observes, plans a short action sequence, acts, repeats.
|
| 2 |
+
|
| 3 |
+
`run_episode` is a generator of plain-dict events so the same loop drives the live web
|
| 4 |
+
UI (streamed over SSE), the benchmark runner, and the trajectory dataset.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import threading
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Iterator, Protocol
|
| 13 |
+
|
| 14 |
+
from .env import Task
|
| 15 |
+
from .llm import ChatClient
|
| 16 |
+
from .render import Frame, legend_for, surroundings, visible_features
|
| 17 |
+
|
| 18 |
+
MAX_PLAN = 8 # most actions Beam may queue in a single `act` call
|
| 19 |
+
HISTORY_LINES = 24 # recent action log lines shown each step
|
| 20 |
+
MAX_IDLE_CALLS = 3 # consecutive replies without an action before we stop
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Env(Protocol):
|
| 24 |
+
task: Task
|
| 25 |
+
action_names: list[str]
|
| 26 |
+
|
| 27 |
+
def reset(self) -> Frame: ...
|
| 28 |
+
def step(self, name: str) -> tuple[Frame, float, bool, bool]: ...
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
SYSTEM_PROMPT = """You are Lantern, an explorer in a procedurally generated dungeon. You see an
|
| 32 |
+
ASCII map each turn, with each row labelled by its y coordinate. Positions are x
|
| 33 |
+
(column, increasing east) and y (row, increasing south). Use the "Visible features"
|
| 34 |
+
list for exact positions and "Around you" for what blocks each direction.
|
| 35 |
+
|
| 36 |
+
Goal: {goal}
|
| 37 |
+
|
| 38 |
+
How to play:
|
| 39 |
+
- Call `act` with a short plan of 1-{max_plan} actions. The plan is cut short if anything
|
| 40 |
+
surprising happens (a new message, lost HP), and you will see the new state.
|
| 41 |
+
- Call `write_notes` whenever you learn something worth remembering this episode:
|
| 42 |
+
where you have been, dead ends, what items do, your current plan. Notes replace the
|
| 43 |
+
previous notes, so keep what still matters.
|
| 44 |
+
- Letters on the map that are not described in the legend are monsters.
|
| 45 |
+
- Compare your x,y with the target's x,y before planning moves.
|
| 46 |
+
{lessons}"""
|
| 47 |
+
|
| 48 |
+
TOOLS = [
|
| 49 |
+
{"type": "function", "function": {
|
| 50 |
+
"name": "act",
|
| 51 |
+
"description": "Execute a short sequence of game actions in order.",
|
| 52 |
+
"parameters": {"type": "object", "properties": {
|
| 53 |
+
"actions": {"type": "array", "minItems": 1, "maxItems": MAX_PLAN,
|
| 54 |
+
"items": {"type": "string", "enum": []}},
|
| 55 |
+
}, "required": ["actions"]},
|
| 56 |
+
}},
|
| 57 |
+
{"type": "function", "function": {
|
| 58 |
+
"name": "write_notes",
|
| 59 |
+
"description": "Replace your working notes for this episode.",
|
| 60 |
+
"parameters": {"type": "object", "properties": {
|
| 61 |
+
"notes": {"type": "string"},
|
| 62 |
+
}, "required": ["notes"]},
|
| 63 |
+
}},
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
REFLECT_PROMPT = """The episode is over. Outcome: {outcome} after {steps} game steps.
|
| 67 |
+
Your final notes were:
|
| 68 |
+
{notes}
|
| 69 |
+
|
| 70 |
+
Recent log:
|
| 71 |
+
{log}
|
| 72 |
+
|
| 73 |
+
Write 1-3 short, concrete lessons that would help you do better on a NEW random layout
|
| 74 |
+
of this same task next time. Generalise; do not reference exact coordinates.
|
| 75 |
+
Reply with JSON only: {{"lessons": ["...", "..."]}}"""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class EpisodeConfig:
|
| 80 |
+
max_llm_calls: int = 120
|
| 81 |
+
use_lessons: bool = True
|
| 82 |
+
reflect: bool = True
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def tools_for(action_names: list[str]) -> list[dict]:
|
| 86 |
+
"""Tool schemas with the `act` enum restricted to this env's legal actions."""
|
| 87 |
+
tools = json.loads(json.dumps(TOOLS))
|
| 88 |
+
tools[0]["function"]["parameters"]["properties"]["actions"]["items"]["enum"] = action_names
|
| 89 |
+
return tools
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def build_observation(task: Task, frame: Frame, notes: str, log: list[str], step: int) -> str:
|
| 93 |
+
"""The per-step user message: notes, recent log, and the current map."""
|
| 94 |
+
s = frame.status
|
| 95 |
+
parts = [
|
| 96 |
+
f"Step {step}/{task.max_steps}. You are at x={s['x']}, y={s['y']}. "
|
| 97 |
+
f"HP {s['hp']}/{s['max_hp']}, {s['hunger']}.",
|
| 98 |
+
f"Message: {frame.message or '(none)'}",
|
| 99 |
+
"Map:\n" + frame.map_text,
|
| 100 |
+
"Around you:\n " + surroundings(frame),
|
| 101 |
+
"Visible features (offset from you):\n"
|
| 102 |
+
+ ("\n".join(f" {f}" for f in visible_features(frame)) or " (none)"),
|
| 103 |
+
"Legend:\n" + (legend_for(frame.rows) or " (nothing notable)"),
|
| 104 |
+
"Your notes:\n" + (notes or "(empty)"),
|
| 105 |
+
"Recent log:\n" + ("\n".join(log[-HISTORY_LINES:]) or "(start of episode)"),
|
| 106 |
+
]
|
| 107 |
+
return "\n\n".join(parts)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _outcome(reward: float, terminated: bool, truncated: bool) -> str | None:
|
| 111 |
+
if terminated:
|
| 112 |
+
return "success" if reward > 0 else "failure"
|
| 113 |
+
if truncated:
|
| 114 |
+
return "timeout"
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def run_episode(env: Env, client: ChatClient, lessons: list[str] | None = None,
|
| 119 |
+
config: EpisodeConfig | None = None,
|
| 120 |
+
stop: threading.Event | None = None) -> Iterator[dict]:
|
| 121 |
+
"""Play one episode, yielding events: start, turn, (lessons), end."""
|
| 122 |
+
config = config or EpisodeConfig()
|
| 123 |
+
task = env.task
|
| 124 |
+
tools = tools_for(env.action_names)
|
| 125 |
+
lesson_block = ""
|
| 126 |
+
if config.use_lessons and lessons:
|
| 127 |
+
lesson_block = "\nLessons from your previous attempts at this task:\n" + \
|
| 128 |
+
"\n".join(f"- {l}" for l in lessons)
|
| 129 |
+
system = SYSTEM_PROMPT.format(goal=task.goal, max_plan=MAX_PLAN, lessons=lesson_block)
|
| 130 |
+
|
| 131 |
+
frame = env.reset()
|
| 132 |
+
notes, log = "", []
|
| 133 |
+
steps, idle, total_reward = 0, 0, 0.0
|
| 134 |
+
outcome: str | None = None
|
| 135 |
+
usage = {"prompt_tokens": 0, "completion_tokens": 0, "calls": 0}
|
| 136 |
+
|
| 137 |
+
yield {"type": "start", "task": task.id, "title": task.title, "goal": task.goal,
|
| 138 |
+
"actions": env.action_names, "lessons": list(lessons or []) if config.use_lessons else [],
|
| 139 |
+
"frame": frame.to_dict()}
|
| 140 |
+
|
| 141 |
+
while outcome is None:
|
| 142 |
+
if stop is not None and stop.is_set():
|
| 143 |
+
outcome = "stopped"
|
| 144 |
+
break
|
| 145 |
+
if usage["calls"] >= config.max_llm_calls:
|
| 146 |
+
outcome = "budget"
|
| 147 |
+
break
|
| 148 |
+
|
| 149 |
+
prompt = build_observation(task, frame, notes, log, steps)
|
| 150 |
+
reply = client.chat([{"role": "system", "content": system},
|
| 151 |
+
{"role": "user", "content": prompt}], tools=tools)
|
| 152 |
+
usage["calls"] += 1
|
| 153 |
+
usage["prompt_tokens"] += reply.usage.get("prompt_tokens", 0)
|
| 154 |
+
usage["completion_tokens"] += reply.usage.get("completion_tokens", 0)
|
| 155 |
+
|
| 156 |
+
executed: list[dict] = []
|
| 157 |
+
start_frame = frame
|
| 158 |
+
for call in reply.tool_calls:
|
| 159 |
+
fn = call.get("function", {})
|
| 160 |
+
try:
|
| 161 |
+
args = json.loads(fn.get("arguments") or "{}")
|
| 162 |
+
except json.JSONDecodeError:
|
| 163 |
+
log.append(f"[{steps}] (malformed tool arguments ignored)")
|
| 164 |
+
continue
|
| 165 |
+
if fn.get("name") == "write_notes":
|
| 166 |
+
notes = str(args.get("notes", ""))[:2000]
|
| 167 |
+
executed.append({"tool": "write_notes", "notes": notes})
|
| 168 |
+
elif fn.get("name") == "act":
|
| 169 |
+
plan = [a for a in args.get("actions", []) if a in env.action_names][:MAX_PLAN]
|
| 170 |
+
done_actions = []
|
| 171 |
+
for name in plan:
|
| 172 |
+
hp_before = frame.status["hp"]
|
| 173 |
+
frame, reward, terminated, truncated = env.step(name)
|
| 174 |
+
steps += 1
|
| 175 |
+
total_reward += reward
|
| 176 |
+
done_actions.append(name)
|
| 177 |
+
outcome = _outcome(reward, terminated, truncated)
|
| 178 |
+
if outcome or frame.message or frame.status["hp"] < hp_before:
|
| 179 |
+
break
|
| 180 |
+
interrupted = len(done_actions) < len(plan) and outcome is None
|
| 181 |
+
log.append(f"[{steps}] {' '.join(done_actions) or '(no valid actions)'}"
|
| 182 |
+
+ (" (interrupted)" if interrupted else "")
|
| 183 |
+
+ (f" -> \"{frame.message}\"" if frame.message else ""))
|
| 184 |
+
executed.append({"tool": "act", "planned": plan, "done": done_actions,
|
| 185 |
+
"interrupted": interrupted})
|
| 186 |
+
if outcome:
|
| 187 |
+
break
|
| 188 |
+
|
| 189 |
+
idle = 0 if any(e["tool"] == "act" for e in executed) else idle + 1
|
| 190 |
+
if not executed and reply.content:
|
| 191 |
+
log.append(f"[{steps}] (you replied without calling a tool; use `act`)")
|
| 192 |
+
|
| 193 |
+
yield {"type": "turn", "call": usage["calls"], "steps": steps,
|
| 194 |
+
"reasoning": reply.reasoning, "content": reply.content,
|
| 195 |
+
"executed": executed, "notes": notes, "latency_s": round(reply.latency_s, 2),
|
| 196 |
+
"prompt": prompt, "before": start_frame.to_dict(), "frame": frame.to_dict()}
|
| 197 |
+
|
| 198 |
+
if idle >= MAX_IDLE_CALLS:
|
| 199 |
+
outcome = "stalled"
|
| 200 |
+
|
| 201 |
+
new_lessons: list[str] = []
|
| 202 |
+
if config.reflect and outcome not in ("stopped",):
|
| 203 |
+
new_lessons = reflect(client, outcome, steps, notes, log)
|
| 204 |
+
yield {"type": "lessons", "lessons": new_lessons}
|
| 205 |
+
|
| 206 |
+
yield {"type": "end", "task": task.id, "outcome": outcome, "steps": steps,
|
| 207 |
+
"reward": total_reward, "usage": usage, "new_lessons": new_lessons}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def reflect(client: ChatClient, outcome: str, steps: int, notes: str, log: list[str]) -> list[str]:
|
| 211 |
+
"""Ask Beam to distil the finished episode into reusable lessons."""
|
| 212 |
+
reply = client.chat([{"role": "user", "content": REFLECT_PROMPT.format(
|
| 213 |
+
outcome=outcome, steps=steps, notes=notes or "(none)",
|
| 214 |
+
log="\n".join(log[-HISTORY_LINES:]) or "(none)")}], max_tokens=2048)
|
| 215 |
+
text = (reply.content or "").strip()
|
| 216 |
+
start, end = text.find("{"), text.rfind("}")
|
| 217 |
+
try:
|
| 218 |
+
lessons = json.loads(text[start:end + 1]).get("lessons", [])
|
| 219 |
+
except (json.JSONDecodeError, ValueError):
|
| 220 |
+
return []
|
| 221 |
+
return [str(l).strip()[:300] for l in lessons if str(l).strip()][:3]
|
lantern/env.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text-facing dungeon environment, built on the MiniHack library (Meta AI, Apache-2.0).
|
| 2 |
+
|
| 3 |
+
MiniHack/NLE only ship Linux wheels, so the import is deferred: the rest of the package
|
| 4 |
+
(rendering, agent loop, tests) works on any OS.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
|
| 11 |
+
from .render import Frame, kind_from_explanation, render_frame
|
| 12 |
+
|
| 13 |
+
OBS_KEYS = ("glyphs", "chars", "colors", "message", "blstats")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def classify_glyphs(glyphs) -> tuple[list[list[str]], dict[tuple[int, int], str]]:
|
| 17 |
+
"""Exact per-cell identity from NLE glyph ids: tile kinds plus monster/item names.
|
| 18 |
+
|
| 19 |
+
Unlike ASCII, glyphs distinguish a tree from a corridor (both '#') or water from lava.
|
| 20 |
+
"""
|
| 21 |
+
from nle import nethack
|
| 22 |
+
|
| 23 |
+
kinds: list[list[str]] = []
|
| 24 |
+
labels: dict[tuple[int, int], str] = {}
|
| 25 |
+
for y, row in enumerate(glyphs):
|
| 26 |
+
out = []
|
| 27 |
+
for x, g in enumerate(row):
|
| 28 |
+
g = int(g)
|
| 29 |
+
if nethack.glyph_is_monster(g):
|
| 30 |
+
out.append("monster")
|
| 31 |
+
labels[(y, x)] = nethack.permonst(nethack.glyph_to_mon(g)).mname
|
| 32 |
+
elif nethack.glyph_is_object(g):
|
| 33 |
+
obj = nethack.objclass(nethack.glyph_to_obj(g))
|
| 34 |
+
desc = nethack.objdescr.from_idx(obj.oc_name_idx)
|
| 35 |
+
name = desc.oc_name or desc.oc_descr or "object"
|
| 36 |
+
out.append("boulder" if name == "boulder" else "item")
|
| 37 |
+
labels[(y, x)] = name
|
| 38 |
+
elif nethack.glyph_is_trap(g):
|
| 39 |
+
out.append("trap")
|
| 40 |
+
elif nethack.glyph_is_cmap(g):
|
| 41 |
+
cmap = nethack.glyph_to_cmap(g)
|
| 42 |
+
out.append("unknown" if cmap == 0 else
|
| 43 |
+
kind_from_explanation(nethack.symdef.from_idx(cmap).explanation))
|
| 44 |
+
else:
|
| 45 |
+
out.append("floor")
|
| 46 |
+
kinds.append(out)
|
| 47 |
+
return kinds, labels
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass(frozen=True)
|
| 51 |
+
class Task:
|
| 52 |
+
"""One benchmark task: a MiniHack level id plus a plain-English goal for the agent."""
|
| 53 |
+
|
| 54 |
+
id: str
|
| 55 |
+
env_id: str
|
| 56 |
+
title: str
|
| 57 |
+
goal: str
|
| 58 |
+
max_steps: int
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
TASKS: dict[str, Task] = {t.id: t for t in [
|
| 62 |
+
Task("room", "MiniHack-Room-5x5-v0", "Small Room",
|
| 63 |
+
"Reach the downstairs '>' in a small lit room.", 50),
|
| 64 |
+
Task("room-monster", "MiniHack-Room-Monster-15x15-v0", "Room with Monsters",
|
| 65 |
+
"Reach '>' in a large room while monsters roam. Avoid dying.", 200),
|
| 66 |
+
Task("corridor", "MiniHack-Corridor-R3-v0", "Corridors",
|
| 67 |
+
"Explore connected rooms through corridors to find '>'.", 300),
|
| 68 |
+
Task("keyroom", "MiniHack-KeyRoom-S5-v0", "Key Room",
|
| 69 |
+
"The '>' is behind a locked door. Find the key '(', pick it up, "
|
| 70 |
+
"stand next to the door and apply the key, then reach '>'.", 200),
|
| 71 |
+
Task("mazewalk", "MiniHack-MazeWalk-15x15-v0", "Maze Walk",
|
| 72 |
+
"Navigate a maze you can only see as you explore it to find '>'.", 300),
|
| 73 |
+
Task("river", "MiniHack-River-v0", "River Crossing",
|
| 74 |
+
"Cross the river '}' to reach '>'. You cannot swim: push boulders '`' "
|
| 75 |
+
"into the water to build a bridge.", 350),
|
| 76 |
+
Task("hidenseek", "MiniHack-HideNSeek-v0", "Hide and Seek",
|
| 77 |
+
"A powerful hostile monster shares this level. Reach '>' before it kills you; "
|
| 78 |
+
"trees and clouds (both drawn '#') block its line of sight.", 200),
|
| 79 |
+
Task("memento", "MiniHack-Memento-Short-F2-v0", "Memento",
|
| 80 |
+
"Kill the grid bug 'x' by moving into it. One branch of the corridor hides a "
|
| 81 |
+
"squeaky board that ends the run in failure. The creature you see in the small "
|
| 82 |
+
"lit room at the start is a clue to which branch is safe.", 200),
|
| 83 |
+
]}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def action_name(action) -> str:
|
| 87 |
+
"""Friendly, stable tool-facing name for an NLE action enum member."""
|
| 88 |
+
compass = {"N": "north", "S": "south", "E": "east", "W": "west",
|
| 89 |
+
"NE": "northeast", "NW": "northwest", "SE": "southeast", "SW": "southwest"}
|
| 90 |
+
kind = type(action).__name__
|
| 91 |
+
if kind == "CompassDirection":
|
| 92 |
+
return compass[action.name]
|
| 93 |
+
if kind == "CompassDirectionLonger":
|
| 94 |
+
return "run_" + compass[action.name]
|
| 95 |
+
if kind == "MiscDirection":
|
| 96 |
+
return {"DOWN": "descend", "UP": "ascend", "WAIT": "wait"}.get(action.name, action.name.lower())
|
| 97 |
+
return action.name.lower()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
class DungeonEnv:
|
| 101 |
+
"""A MiniHack gymnasium env that speaks in named actions and text `Frame`s.
|
| 102 |
+
|
| 103 |
+
`step()` takes an action *name* (from `action_names`) rather than an index, so the
|
| 104 |
+
LLM's tool schema and the environment can never drift out of sync.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(self, task: Task, seed: int | None = None):
|
| 108 |
+
import gymnasium as gym
|
| 109 |
+
import minihack # noqa: F401 (registers MiniHack-* env ids)
|
| 110 |
+
|
| 111 |
+
self.task = task
|
| 112 |
+
self.seed = seed
|
| 113 |
+
self._env = gym.make(task.env_id, observation_keys=OBS_KEYS,
|
| 114 |
+
max_episode_steps=task.max_steps)
|
| 115 |
+
actions = self._env.unwrapped.actions
|
| 116 |
+
self._index = {action_name(a): i for i, a in enumerate(actions)}
|
| 117 |
+
self.action_names = list(self._index)
|
| 118 |
+
|
| 119 |
+
def reset(self) -> Frame:
|
| 120 |
+
obs, _ = self._env.reset(seed=self.seed)
|
| 121 |
+
return self._frame(obs)
|
| 122 |
+
|
| 123 |
+
def step(self, name: str) -> tuple[Frame, float, bool, bool]:
|
| 124 |
+
"""Apply one named action. Returns (frame, reward, terminated, truncated)."""
|
| 125 |
+
if name not in self._index:
|
| 126 |
+
raise ValueError(f"Unknown action {name!r}; allowed: {self.action_names}")
|
| 127 |
+
obs, reward, terminated, truncated, _ = self._env.step(self._index[name])
|
| 128 |
+
return self._frame(obs), float(reward), bool(terminated), bool(truncated)
|
| 129 |
+
|
| 130 |
+
def close(self) -> None:
|
| 131 |
+
self._env.close()
|
| 132 |
+
|
| 133 |
+
@staticmethod
|
| 134 |
+
def _frame(obs) -> Frame:
|
| 135 |
+
kinds, labels = classify_glyphs(obs["glyphs"])
|
| 136 |
+
return render_frame(obs["chars"], obs["colors"], obs["message"], obs["blstats"],
|
| 137 |
+
kinds=kinds, labels=labels)
|
lantern/journal.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The lessons journal: what Beam carries from one episode of a task to the next."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def merge_lessons(old: list[str], new: list[str], limit: int) -> list[str]:
|
| 10 |
+
"""Combine existing lessons with ones Beam just wrote after an episode.
|
| 11 |
+
|
| 12 |
+
Returns at most `limit` lessons, oldest first. Newer lessons win: when the journal is
|
| 13 |
+
full the oldest fall off, and a lesson Beam writes again (ignoring case/spacing) moves
|
| 14 |
+
to the end instead of appearing twice, so repeatedly confirmed lessons survive longest.
|
| 15 |
+
"""
|
| 16 |
+
by_key: dict[str, str] = {}
|
| 17 |
+
for lesson in [*old, *new]:
|
| 18 |
+
key = " ".join(lesson.lower().split())
|
| 19 |
+
if not key:
|
| 20 |
+
continue
|
| 21 |
+
by_key.pop(key, None)
|
| 22 |
+
by_key[key] = lesson.strip()
|
| 23 |
+
return list(by_key.values())[-limit:]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class Journal:
|
| 27 |
+
"""Per-task lesson lists stored as one JSON file."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, path: Path, limit: int = 12):
|
| 30 |
+
self.path = Path(path)
|
| 31 |
+
self.limit = limit
|
| 32 |
+
self._data: dict[str, list[str]] = (
|
| 33 |
+
json.loads(self.path.read_text("utf-8")) if self.path.exists() else {}
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def lessons(self, task_id: str) -> list[str]:
|
| 37 |
+
return list(self._data.get(task_id, []))
|
| 38 |
+
|
| 39 |
+
def add(self, task_id: str, new: list[str]) -> list[str]:
|
| 40 |
+
"""Merge new lessons for a task, persist, and return the updated list."""
|
| 41 |
+
merged = merge_lessons(self.lessons(task_id), new, self.limit)
|
| 42 |
+
self._data[task_id] = merged
|
| 43 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
self.path.write_text(json.dumps(self._data, indent=2), "utf-8")
|
| 45 |
+
return merged
|
lantern/llm.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal OpenAI-compatible chat client for Beam via the Hugging Face router.
|
| 2 |
+
|
| 3 |
+
The token is read from the HF_TOKEN environment variable (a Space secret in
|
| 4 |
+
production) or the local `hf auth login` cache. It is never logged.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
|
| 17 |
+
BEAM_MODEL = "reflection/Beam-1.0-501B-A23B"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _load_token() -> str:
|
| 21 |
+
token = os.environ.get("HF_TOKEN")
|
| 22 |
+
if token:
|
| 23 |
+
return token.strip()
|
| 24 |
+
cached = Path.home() / ".cache" / "huggingface" / "token"
|
| 25 |
+
if cached.exists():
|
| 26 |
+
return cached.read_text().strip()
|
| 27 |
+
raise RuntimeError("No Hugging Face token: set HF_TOKEN or run `hf auth login`.")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass
|
| 31 |
+
class Reply:
|
| 32 |
+
"""The parts of a chat completion the agent cares about."""
|
| 33 |
+
|
| 34 |
+
content: str | None
|
| 35 |
+
reasoning: str | None
|
| 36 |
+
tool_calls: list[dict]
|
| 37 |
+
usage: dict = field(default_factory=dict)
|
| 38 |
+
latency_s: float = 0.0
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ChatClient:
|
| 42 |
+
"""Calls an OpenAI-compatible endpoint; defaults to Beam on the HF router.
|
| 43 |
+
|
| 44 |
+
`base_url`/`model` can point at any compatible server, which is how other models
|
| 45 |
+
are benchmarked with the same harness.
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
def __init__(self, model: str = BEAM_MODEL, url: str = ROUTER_URL,
|
| 49 |
+
token: str | None = None, timeout_s: float = 120.0, retries: int = 3):
|
| 50 |
+
self.model = model
|
| 51 |
+
self.url = url
|
| 52 |
+
self._headers = {"Authorization": f"Bearer {token or _load_token()}"}
|
| 53 |
+
self._http = httpx.Client(timeout=timeout_s)
|
| 54 |
+
self._retries = retries
|
| 55 |
+
|
| 56 |
+
def chat(self, messages: list[dict], tools: list[dict] | None = None,
|
| 57 |
+
temperature: float = 0.7, max_tokens: int = 4096) -> Reply:
|
| 58 |
+
"""Send one chat completion request, retrying transient failures."""
|
| 59 |
+
body = {"model": self.model, "messages": messages, "temperature": temperature,
|
| 60 |
+
"top_p": 0.99, "max_tokens": max_tokens}
|
| 61 |
+
if tools:
|
| 62 |
+
body["tools"] = tools
|
| 63 |
+
body["tool_choice"] = "auto"
|
| 64 |
+
|
| 65 |
+
for attempt in range(self._retries):
|
| 66 |
+
start = time.monotonic()
|
| 67 |
+
resp = self._http.post(self.url, json=body, headers=self._headers)
|
| 68 |
+
if resp.status_code in (429, 500, 502, 503, 504) and attempt < self._retries - 1:
|
| 69 |
+
time.sleep(2 ** attempt)
|
| 70 |
+
continue
|
| 71 |
+
resp.raise_for_status()
|
| 72 |
+
data = resp.json()
|
| 73 |
+
msg = data["choices"][0]["message"]
|
| 74 |
+
return Reply(
|
| 75 |
+
content=msg.get("content"),
|
| 76 |
+
reasoning=msg.get("reasoning") or msg.get("reasoning_content"),
|
| 77 |
+
tool_calls=msg.get("tool_calls") or [],
|
| 78 |
+
usage=data.get("usage") or {},
|
| 79 |
+
latency_s=time.monotonic() - start,
|
| 80 |
+
)
|
| 81 |
+
raise RuntimeError("unreachable")
|
lantern/render.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn raw NLE observation arrays into text an LLM (and the web UI) can read.
|
| 2 |
+
|
| 3 |
+
Pure functions over numpy-like arrays so they can be tested without NLE installed.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from dataclasses import asdict, dataclass, field
|
| 9 |
+
|
| 10 |
+
# Indices into the NLE `blstats` vector (see nle/include/nletypes.h).
|
| 11 |
+
BL_X, BL_Y, BL_HP, BL_HPMAX, BL_DEPTH, BL_TIME, BL_HUNGER = 0, 1, 10, 11, 12, 20, 21
|
| 12 |
+
|
| 13 |
+
HUNGER_STATES = ["not hungry", "hungry", "weak", "fainting", "fainted", "starved", "satiated"]
|
| 14 |
+
|
| 15 |
+
LEGEND = {
|
| 16 |
+
"@": "you (or a human monster)",
|
| 17 |
+
">": "stairs down (usually the goal)",
|
| 18 |
+
"<": "stairs up",
|
| 19 |
+
".": "floor",
|
| 20 |
+
"#": "corridor, tree, or cloud (trees and clouds block sight)",
|
| 21 |
+
"|": "wall",
|
| 22 |
+
"-": "wall (or open door)",
|
| 23 |
+
"+": "closed door (or spellbook)",
|
| 24 |
+
"}": "water or lava",
|
| 25 |
+
"(": "tool or weapon (e.g. a key)",
|
| 26 |
+
"!": "potion",
|
| 27 |
+
"=": "ring",
|
| 28 |
+
"/": "wand",
|
| 29 |
+
"^": "trap",
|
| 30 |
+
"`": "boulder / rock",
|
| 31 |
+
}
|
| 32 |
+
# Any other letter on the map is a monster; the UI and prompt say so generically.
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class Frame:
|
| 37 |
+
"""A text snapshot of one game state."""
|
| 38 |
+
|
| 39 |
+
map_text: str # cropped ASCII map with y row labels
|
| 40 |
+
message: str # the top-line game message, if any
|
| 41 |
+
status: dict # hero position, hp, turn, ...
|
| 42 |
+
origin: tuple[int, int] # (row, col) of the crop's top-left in the full 21x79 map
|
| 43 |
+
rows: list[str] # cropped map rows without rulers (for the UI)
|
| 44 |
+
colors: list[list[int]] # NLE color index per cropped cell (for the UI)
|
| 45 |
+
kinds: list[list[str]] = field(default_factory=list) # cell identity for the 3D view
|
| 46 |
+
labels: list[list] = field(default_factory=list) # [row, col, name] of monsters/items
|
| 47 |
+
|
| 48 |
+
def to_dict(self) -> dict:
|
| 49 |
+
return asdict(self)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def decode_message(message) -> str:
|
| 53 |
+
"""Decode NLE's null-padded uint8 message array into a string."""
|
| 54 |
+
raw = bytes(int(c) for c in message)
|
| 55 |
+
return raw.split(b"\0", 1)[0].decode("latin-1").strip()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def parse_status(blstats) -> dict:
|
| 59 |
+
"""Pick the human-relevant fields out of the blstats vector."""
|
| 60 |
+
hunger = int(blstats[BL_HUNGER])
|
| 61 |
+
return {
|
| 62 |
+
"x": int(blstats[BL_X]),
|
| 63 |
+
"y": int(blstats[BL_Y]),
|
| 64 |
+
"hp": int(blstats[BL_HP]),
|
| 65 |
+
"max_hp": int(blstats[BL_HPMAX]),
|
| 66 |
+
"depth": int(blstats[BL_DEPTH]),
|
| 67 |
+
"turn": int(blstats[BL_TIME]),
|
| 68 |
+
"hunger": HUNGER_STATES[hunger] if 0 <= hunger < len(HUNGER_STATES) else str(hunger),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _bounding_box(rows: list[str]) -> tuple[int, int, int, int] | None:
|
| 73 |
+
"""(top, bottom, left, right) inclusive box around non-blank cells, or None if empty."""
|
| 74 |
+
filled = [(r, c) for r, line in enumerate(rows) for c, ch in enumerate(line) if ch != " "]
|
| 75 |
+
if not filled:
|
| 76 |
+
return None
|
| 77 |
+
rs = [r for r, _ in filled]
|
| 78 |
+
cs = [c for _, c in filled]
|
| 79 |
+
return min(rs), max(rs), min(cs), max(cs)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def kind_from_explanation(explanation: str) -> str:
|
| 83 |
+
"""Map NetHack's map-symbol explanation (e.g. "open door") to a 3D tile kind."""
|
| 84 |
+
e = explanation.lower()
|
| 85 |
+
rules = [
|
| 86 |
+
("staircase down", "stairs_down"), ("ladder down", "stairs_down"),
|
| 87 |
+
("staircase up", "stairs_up"), ("ladder up", "stairs_up"),
|
| 88 |
+
("closed door", "door_closed"), ("open door", "door_open"),
|
| 89 |
+
("doorway", "doorway"), ("broken door", "doorway"),
|
| 90 |
+
("lava", "lava"), ("water", "water"), ("moat", "water"), ("pool", "water"),
|
| 91 |
+
("tree", "tree"), ("cloud", "cloud"), ("ice", "ice"), ("trap", "trap"),
|
| 92 |
+
("corridor", "corridor"), ("wall", "wall"), ("corner", "wall"),
|
| 93 |
+
("tee", "wall"), ("crosswall", "wall"),
|
| 94 |
+
("fountain", "feature"), ("altar", "feature"), ("sink", "feature"),
|
| 95 |
+
("throne", "feature"), ("grave", "feature"),
|
| 96 |
+
("floor", "floor"), ("dark part of a room", "floor"),
|
| 97 |
+
]
|
| 98 |
+
for needle, kind in rules:
|
| 99 |
+
if needle in e:
|
| 100 |
+
return kind
|
| 101 |
+
return "floor"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# Fallback when glyph identities are unavailable (e.g. the dev preview room).
|
| 105 |
+
_CHAR_KINDS = {"|": "wall", "-": "wall", ".": "floor", "#": "corridor", ">": "stairs_down",
|
| 106 |
+
"<": "stairs_up", "+": "door_closed", "}": "water", "^": "trap", "`": "boulder"}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def kinds_from_chars(rows: list[str]) -> list[list[str]]:
|
| 110 |
+
"""Best-effort kinds from ASCII alone; ambiguous symbols fall back to their common case."""
|
| 111 |
+
def kind(ch: str) -> str:
|
| 112 |
+
if ch == " ":
|
| 113 |
+
return "unknown"
|
| 114 |
+
if ch in _CHAR_KINDS:
|
| 115 |
+
return _CHAR_KINDS[ch]
|
| 116 |
+
return "monster" if ch.isalpha() or ch == "@" else "item"
|
| 117 |
+
return [[kind(ch) for ch in row] for row in rows]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def render_frame(chars, colors, message, blstats, margin: int = 1,
|
| 121 |
+
kinds: list[list[str]] | None = None,
|
| 122 |
+
labels: dict[tuple[int, int], str] | None = None) -> Frame:
|
| 123 |
+
"""Crop the 21x79 map to its visible area and label each row with its game y.
|
| 124 |
+
|
| 125 |
+
`kinds`/`labels` (from NLE glyphs) give exact cell identities for the 3D view; without
|
| 126 |
+
them, kinds are inferred from the ASCII characters.
|
| 127 |
+
"""
|
| 128 |
+
full = ["".join(chr(int(c)) for c in row) for row in chars]
|
| 129 |
+
box = _bounding_box(full)
|
| 130 |
+
if box is None:
|
| 131 |
+
top, bottom, left, right = 0, 0, 0, 0
|
| 132 |
+
else:
|
| 133 |
+
top, bottom, left, right = box
|
| 134 |
+
top, left = max(0, top - margin), max(0, left - margin)
|
| 135 |
+
bottom = min(len(full) - 1, bottom + margin)
|
| 136 |
+
right = min(len(full[0]) - 1, right + margin)
|
| 137 |
+
|
| 138 |
+
rows = [line[left : right + 1] for line in full[top : bottom + 1]]
|
| 139 |
+
color_rows = [[int(v) for v in row[left : right + 1]] for row in list(colors)[top : bottom + 1]]
|
| 140 |
+
|
| 141 |
+
# Row labels only: column rulers cost the model more effort to decode than they saved.
|
| 142 |
+
lines = [f"y={top + i:<3} {row}" for i, row in enumerate(rows)]
|
| 143 |
+
|
| 144 |
+
status = parse_status(blstats)
|
| 145 |
+
if kinds is None:
|
| 146 |
+
kind_rows = kinds_from_chars(rows)
|
| 147 |
+
else:
|
| 148 |
+
kind_rows = [list(row[left : right + 1]) for row in kinds[top : bottom + 1]]
|
| 149 |
+
hy, hx = status["y"] - top, status["x"] - left
|
| 150 |
+
if 0 <= hy < len(kind_rows) and 0 <= hx < len(kind_rows[hy]):
|
| 151 |
+
kind_rows[hy][hx] = "hero"
|
| 152 |
+
label_list = [[y - top, x - left, name] for (y, x), name in (labels or {}).items()
|
| 153 |
+
if top <= y <= bottom and left <= x <= right and (y, x) != (status["y"], status["x"])]
|
| 154 |
+
|
| 155 |
+
return Frame(
|
| 156 |
+
map_text="\n".join(lines),
|
| 157 |
+
message=decode_message(message),
|
| 158 |
+
status=status,
|
| 159 |
+
origin=(top, left),
|
| 160 |
+
rows=rows,
|
| 161 |
+
colors=color_rows,
|
| 162 |
+
kinds=kind_rows,
|
| 163 |
+
labels=label_list,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
FEATURE_SYMBOLS = set(LEGEND) - {"@", ".", "#", "|", "-", "}"}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _offset_words(dx: int, dy: int) -> str:
|
| 171 |
+
parts = []
|
| 172 |
+
if dx:
|
| 173 |
+
parts.append(f"{abs(dx)} {'east' if dx > 0 else 'west'}")
|
| 174 |
+
if dy:
|
| 175 |
+
parts.append(f"{abs(dy)} {'south' if dy > 0 else 'north'}")
|
| 176 |
+
return ", ".join(parts) or "here"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def visible_features(frame: Frame, limit: int = 15) -> list[str]:
|
| 180 |
+
"""Notable symbols (stairs, items, doors, monsters) with their offset from the hero.
|
| 181 |
+
|
| 182 |
+
Saves the model from counting characters in the ASCII grid, so the benchmark
|
| 183 |
+
measures planning and memory rather than text-alignment perception.
|
| 184 |
+
"""
|
| 185 |
+
top, left = frame.origin
|
| 186 |
+
hx, hy = frame.status["x"], frame.status["y"]
|
| 187 |
+
found = []
|
| 188 |
+
for r, row in enumerate(frame.rows):
|
| 189 |
+
for c, ch in enumerate(row):
|
| 190 |
+
x, y = c + left, r + top
|
| 191 |
+
if (x, y) == (hx, hy):
|
| 192 |
+
continue
|
| 193 |
+
if ch in FEATURE_SYMBOLS:
|
| 194 |
+
desc = LEGEND[ch]
|
| 195 |
+
elif ch.isalpha() or ch == "@":
|
| 196 |
+
desc = "monster"
|
| 197 |
+
else:
|
| 198 |
+
continue
|
| 199 |
+
dx, dy = x - hx, y - hy
|
| 200 |
+
found.append((max(abs(dx), abs(dy)), f"'{ch}' {desc} at x={x}, y={y}: {_offset_words(dx, dy)}"))
|
| 201 |
+
found.sort(key=lambda f: f[0])
|
| 202 |
+
return [text for _, text in found[:limit]]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
_DIRECTIONS = [("north", 0, -1), ("northeast", 1, -1), ("east", 1, 0), ("southeast", 1, 1),
|
| 206 |
+
("south", 0, 1), ("southwest", -1, 1), ("west", -1, 0), ("northwest", -1, -1)]
|
| 207 |
+
_KIND_WORDS = {"unknown": "unexplored", "door_closed": "closed door", "door_open": "open door",
|
| 208 |
+
"stairs_down": "stairs down", "stairs_up": "stairs up"}
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def surroundings(frame: Frame) -> str:
|
| 212 |
+
"""What occupies each of the 8 cells next to the hero, e.g. "north: wall, east: floor"."""
|
| 213 |
+
top, left = frame.origin
|
| 214 |
+
hx, hy = frame.status["x"] - left, frame.status["y"] - top
|
| 215 |
+
parts = []
|
| 216 |
+
for name, dx, dy in _DIRECTIONS:
|
| 217 |
+
r, c = hy + dy, hx + dx
|
| 218 |
+
inside = 0 <= r < len(frame.kinds) and 0 <= c < len(frame.kinds[r])
|
| 219 |
+
kind = frame.kinds[r][c] if inside else "unknown"
|
| 220 |
+
parts.append(f"{name}: {_KIND_WORDS.get(kind, kind)}")
|
| 221 |
+
return ", ".join(parts)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def legend_for(rows: list[str]) -> str:
|
| 225 |
+
"""Legend lines for only the symbols currently on screen, to keep prompts short."""
|
| 226 |
+
seen = {ch for row in rows for ch in row}
|
| 227 |
+
return "\n".join(f" {sym} {desc}" for sym, desc in LEGEND.items() if sym in seen)
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.49.0
|
| 3 |
+
httpx==0.28.1
|
| 4 |
+
pydantic>=2.7,<3
|
| 5 |
+
nle==1.3.0
|
| 6 |
+
minihack==1.0.2
|
web/app.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Lantern front end: renders live (SSE) or recorded episodes. No framework, no build.
|
| 2 |
+
"use strict";
|
| 3 |
+
|
| 4 |
+
const $ = (id) => document.getElementById(id);
|
| 5 |
+
|
| 6 |
+
// NLE colour indices (curses palette) mapped onto the cave palette.
|
| 7 |
+
const NLE_COLORS = [
|
| 8 |
+
"#5b6375", "#d0685f", "#7fb07a", "#b88a4e", "#6f8fd6", "#b07cc6", "#6fb8c0", "#c9c3b5",
|
| 9 |
+
"#8a93a6", "#e8955a", "#a6dc8e", "#f2d36b", "#8fb2ff", "#de9ce6", "#93e0e6", "#f4efe4",
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
const ARROWS = {
|
| 13 |
+
north: "โ north", south: "โ south", east: "โ east", west: "โ west",
|
| 14 |
+
northeast: "โ northeast", northwest: "โ northwest", southeast: "โ southeast", southwest: "โ southwest",
|
| 15 |
+
pickup: "pick up", apply: "apply", descend: "go down", wait: "wait",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const state = {
|
| 19 |
+
tasks: [],
|
| 20 |
+
events: [],
|
| 21 |
+
turns: [], // indices into events of type "turn"
|
| 22 |
+
selected: -1, // index into turns
|
| 23 |
+
follow: true, // stick to the newest turn while live
|
| 24 |
+
live: null, // { id, source }
|
| 25 |
+
replayTimer: null,
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
// โโ data helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 29 |
+
async function api(path, options) {
|
| 30 |
+
const res = await fetch(path, options);
|
| 31 |
+
const body = await res.json().catch(() => ({}));
|
| 32 |
+
if (!res.ok) throw new Error(body.detail || `Request failed (${res.status}).`);
|
| 33 |
+
return body;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function startEvent() { return state.events.find((e) => e.type === "start"); }
|
| 37 |
+
function endEvent() { return state.events.find((e) => e.type === "end"); }
|
| 38 |
+
function taskTitle(id) { return (state.tasks.find((t) => t.id === id) || {}).title || id; }
|
| 39 |
+
|
| 40 |
+
// โโ map โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 41 |
+
function renderMap(frame, trail) {
|
| 42 |
+
const map = $("map");
|
| 43 |
+
map.replaceChildren();
|
| 44 |
+
if (!frame) return;
|
| 45 |
+
state.lastFrame = frame;
|
| 46 |
+
if (window.Lantern3D) window.Lantern3D.update(frame);
|
| 47 |
+
const [top, left] = frame.origin;
|
| 48 |
+
const hx = frame.status.x - left;
|
| 49 |
+
const hy = frame.status.y - top;
|
| 50 |
+
let heroEl = null;
|
| 51 |
+
|
| 52 |
+
frame.rows.forEach((row, r) => {
|
| 53 |
+
const line = document.createElement("div");
|
| 54 |
+
line.className = "row";
|
| 55 |
+
[...row].forEach((ch, c) => {
|
| 56 |
+
const span = document.createElement("span");
|
| 57 |
+
span.textContent = ch;
|
| 58 |
+
const d = Math.max(Math.abs(r - hy), Math.abs(c - hx));
|
| 59 |
+
// Lamplight falls off with distance; explored-but-distant cells stay dimly remembered.
|
| 60 |
+
span.style.opacity = d <= 1 ? 1 : Math.max(0.36, 1 - (d - 1) * 0.085).toFixed(2);
|
| 61 |
+
if (r === hy && c === hx) { span.className = "hero"; heroEl = span; }
|
| 62 |
+
else if (trail.has(`${c + left},${r + top}`) && ch !== " ") span.className = "trail";
|
| 63 |
+
else span.style.color = NLE_COLORS[frame.colors[r][c] & 15] || NLE_COLORS[7];
|
| 64 |
+
line.appendChild(span);
|
| 65 |
+
});
|
| 66 |
+
map.appendChild(line);
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
if (heroEl) {
|
| 70 |
+
const m = map.getBoundingClientRect();
|
| 71 |
+
const h = heroEl.getBoundingClientRect();
|
| 72 |
+
map.style.setProperty("--hx", `${h.left - m.left + h.width / 2}px`);
|
| 73 |
+
map.style.setProperty("--hy", `${h.top - m.top + h.height / 2}px`);
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function trailUpTo(turnIdx) {
|
| 78 |
+
const trail = new Set();
|
| 79 |
+
const start = startEvent();
|
| 80 |
+
if (start) trail.add(`${start.frame.status.x},${start.frame.status.y}`);
|
| 81 |
+
for (let i = 0; i <= turnIdx; i++) {
|
| 82 |
+
const s = state.events[state.turns[i]].frame.status;
|
| 83 |
+
trail.add(`${s.x},${s.y}`);
|
| 84 |
+
}
|
| 85 |
+
return trail;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// โโ panels โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 89 |
+
function renderMind(turn) {
|
| 90 |
+
const reasoning = $("reasoning");
|
| 91 |
+
reasoning.replaceChildren();
|
| 92 |
+
const text = turn ? (turn.reasoning || turn.content || "") : "";
|
| 93 |
+
if (!text) {
|
| 94 |
+
reasoning.innerHTML = `<p class="quiet">${turn ? "Beam acted without writing out its reasoning." : "Beam's reasoning for each move appears here."}</p>`;
|
| 95 |
+
} else {
|
| 96 |
+
text.split(/\n{2,}/).forEach((para) => {
|
| 97 |
+
const p = document.createElement("p");
|
| 98 |
+
p.textContent = para.trim();
|
| 99 |
+
reasoning.appendChild(p);
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
const plan = $("plan");
|
| 104 |
+
plan.replaceChildren();
|
| 105 |
+
(turn ? turn.executed : []).filter((e) => e.tool === "act").forEach((e) => {
|
| 106 |
+
e.planned.forEach((a, i) => {
|
| 107 |
+
const li = document.createElement("li");
|
| 108 |
+
li.textContent = ARROWS[a] || a.replace(/_/g, " ");
|
| 109 |
+
if (i >= e.done.length) li.className = "skipped";
|
| 110 |
+
plan.appendChild(li);
|
| 111 |
+
});
|
| 112 |
+
if (e.interrupted) {
|
| 113 |
+
const li = document.createElement("li");
|
| 114 |
+
li.className = "why";
|
| 115 |
+
li.textContent = "Stopped early: something changed.";
|
| 116 |
+
plan.appendChild(li);
|
| 117 |
+
}
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
$("notes").textContent = turn && turn.notes ? turn.notes : "";
|
| 121 |
+
if (!$("notes").textContent) $("notes").innerHTML = '<span class="quiet">Nothing yet.</span>';
|
| 122 |
+
$("prompt").textContent = turn ? turn.prompt : "";
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function renderStatus(turn) {
|
| 126 |
+
const status = $("status");
|
| 127 |
+
const end = endEvent();
|
| 128 |
+
const frame = turn ? turn.frame : (startEvent() || {}).frame;
|
| 129 |
+
if (!frame) return;
|
| 130 |
+
const s = frame.status;
|
| 131 |
+
const parts = [`HP <strong>${s.hp}/${s.max_hp}</strong>`];
|
| 132 |
+
if (turn) parts.push(`step <strong>${turn.steps}</strong>`, `call <strong>${turn.call}</strong> of ${state.turns.length}`);
|
| 133 |
+
if (frame.message) parts.push(`โ${escapeHtml(frame.message)}โ`);
|
| 134 |
+
const isLast = state.selected === state.turns.length - 1;
|
| 135 |
+
if (end && isLast) parts.unshift(outcomeHtml(end));
|
| 136 |
+
status.innerHTML = parts.join(" ");
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const OUTCOMES = {
|
| 140 |
+
success: ["win", "Goal reached"],
|
| 141 |
+
failure: ["loss", "Beam died or failed"],
|
| 142 |
+
timeout: ["loss", "Ran out of steps"],
|
| 143 |
+
stalled: ["loss", "Stalled without acting"],
|
| 144 |
+
budget: ["loss", "Hit the call budget"],
|
| 145 |
+
stopped: ["", "Stopped"],
|
| 146 |
+
};
|
| 147 |
+
function outcomeHtml(end) {
|
| 148 |
+
const [cls, label] = OUTCOMES[end.outcome] || ["", escapeHtml(end.outcome)];
|
| 149 |
+
const tokens = end.usage.prompt_tokens + end.usage.completion_tokens;
|
| 150 |
+
return `<strong class="${cls}">${label}</strong> in ${end.steps} steps, ${end.usage.calls} calls, ${tokens.toLocaleString()} tokens.`;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function renderTimeline() {
|
| 154 |
+
const tl = $("timeline");
|
| 155 |
+
const end = endEvent();
|
| 156 |
+
// Add only the ticks that are new, so live updates do not rebuild the whole strip.
|
| 157 |
+
while (tl.children.length < state.turns.length) {
|
| 158 |
+
const i = tl.children.length;
|
| 159 |
+
const t = state.events[state.turns[i]];
|
| 160 |
+
const acts = t.executed.filter((e) => e.tool === "act");
|
| 161 |
+
const done = acts.reduce((n, e) => n + e.done.length, 0);
|
| 162 |
+
const b = document.createElement("button");
|
| 163 |
+
b.type = "button";
|
| 164 |
+
b.className = "tick" + (done ? " act" : "") + (acts.some((e) => e.interrupted) ? " interrupted" : "");
|
| 165 |
+
b.style.height = `${8 + Math.min(done, 8) * 4.5}px`;
|
| 166 |
+
b.setAttribute("role", "option");
|
| 167 |
+
b.setAttribute("aria-label", `Call ${i + 1}: ${done} action${done === 1 ? "" : "s"}`);
|
| 168 |
+
b.addEventListener("click", () => { stopReplay(); select(i, false); });
|
| 169 |
+
tl.appendChild(b);
|
| 170 |
+
}
|
| 171 |
+
if (end && tl.lastElementChild) {
|
| 172 |
+
tl.lastElementChild.classList.add(end.outcome === "success" ? "win" : end.outcome === "stopped" ? "act" : "loss");
|
| 173 |
+
}
|
| 174 |
+
[...tl.children].forEach((b, i) => b.setAttribute("aria-selected", String(i === state.selected)));
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function renderJournal() {
|
| 178 |
+
const start = startEvent();
|
| 179 |
+
const lessonsEv = state.events.find((e) => e.type === "lessons");
|
| 180 |
+
fillList($("lessons-in"), (start && start.lessons) || [], "Nothing yet. This is a fresh start.");
|
| 181 |
+
fillList($("lessons-out"), lessonsEv ? lessonsEv.lessons : [], endEvent() ? "No new lessons." : "Written when the run ends.", "new");
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
function fillList(ul, items, emptyText, cls = "") {
|
| 185 |
+
ul.replaceChildren();
|
| 186 |
+
if (!items.length) {
|
| 187 |
+
const li = document.createElement("li");
|
| 188 |
+
li.className = "quiet";
|
| 189 |
+
li.style.listStyle = "none";
|
| 190 |
+
li.textContent = emptyText;
|
| 191 |
+
ul.appendChild(li);
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
items.forEach((t) => { const li = document.createElement("li"); li.textContent = t; if (cls) li.className = cls; ul.appendChild(li); });
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
function select(i, follow) {
|
| 198 |
+
if (i < 0 || i >= state.turns.length) return;
|
| 199 |
+
state.selected = i;
|
| 200 |
+
state.follow = follow;
|
| 201 |
+
const turn = state.events[state.turns[i]];
|
| 202 |
+
renderMap(turn.frame, trailUpTo(i));
|
| 203 |
+
renderMind(turn);
|
| 204 |
+
renderStatus(turn);
|
| 205 |
+
renderTimeline();
|
| 206 |
+
$("follow").hidden = !(state.live && !follow);
|
| 207 |
+
const tick = $("timeline").children[i];
|
| 208 |
+
if (tick) tick.scrollIntoView({ block: "nearest", inline: "nearest" });
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// โโ event intake โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 212 |
+
function resetView() {
|
| 213 |
+
stopReplay();
|
| 214 |
+
state.events = []; state.turns = []; state.selected = -1; state.follow = true;
|
| 215 |
+
$("timeline").replaceChildren();
|
| 216 |
+
renderMind(null);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
function ingest(ev) {
|
| 220 |
+
state.events.push(ev);
|
| 221 |
+
if (ev.type === "start") {
|
| 222 |
+
$("goal").textContent = `${ev.title}. ${ev.goal}`;
|
| 223 |
+
renderMap(ev.frame, new Set());
|
| 224 |
+
renderStatus(null);
|
| 225 |
+
renderJournal();
|
| 226 |
+
} else if (ev.type === "turn") {
|
| 227 |
+
state.turns.push(state.events.length - 1);
|
| 228 |
+
if (state.follow) select(state.turns.length - 1, true); else renderTimeline();
|
| 229 |
+
} else if (ev.type === "lessons" || ev.type === "end") {
|
| 230 |
+
renderJournal();
|
| 231 |
+
renderTimeline();
|
| 232 |
+
if (state.turns.length) renderStatus(state.events[state.turns[state.selected]]);
|
| 233 |
+
} else if (ev.type === "error") {
|
| 234 |
+
$("status").textContent = `The run failed: ${ev.message}`;
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
// โโ live runs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 239 |
+
async function startRun(evt) {
|
| 240 |
+
evt.preventDefault();
|
| 241 |
+
const body = { task: $("task").value, seed: Number($("seed").value) || 0, use_lessons: $("lessons").checked };
|
| 242 |
+
$("start").disabled = true;
|
| 243 |
+
try {
|
| 244 |
+
const { id } = await api("/api/runs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
| 245 |
+
watch(id);
|
| 246 |
+
} catch (err) {
|
| 247 |
+
$("status").textContent = err.message;
|
| 248 |
+
$("start").disabled = false;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function watch(id) {
|
| 253 |
+
resetView();
|
| 254 |
+
$("status").textContent = "Lighting the lanternโฆ";
|
| 255 |
+
const source = new EventSource(`/api/runs/${id}/events`);
|
| 256 |
+
state.live = { id, source };
|
| 257 |
+
$("stop").hidden = false;
|
| 258 |
+
$("start").disabled = true;
|
| 259 |
+
source.onmessage = (m) => ingest(JSON.parse(m.data));
|
| 260 |
+
source.addEventListener("close", finishLive);
|
| 261 |
+
source.onerror = () => { if (source.readyState === EventSource.CLOSED) finishLive(); };
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function finishLive() {
|
| 265 |
+
if (!state.live) return;
|
| 266 |
+
state.live.source.close();
|
| 267 |
+
state.live = null;
|
| 268 |
+
$("stop").hidden = true;
|
| 269 |
+
$("stop").disabled = false;
|
| 270 |
+
$("start").disabled = false;
|
| 271 |
+
$("follow").hidden = true;
|
| 272 |
+
loadEpisodes();
|
| 273 |
+
loadTasks();
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
async function stopRun() {
|
| 277 |
+
if (!state.live) return;
|
| 278 |
+
$("stop").disabled = true;
|
| 279 |
+
await api(`/api/runs/${state.live.id}/stop`, { method: "POST" }).catch(() => {});
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
// โโ replays โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 283 |
+
async function openReplay(id) {
|
| 284 |
+
if (state.live) return;
|
| 285 |
+
const rec = await api(`/api/episodes/${id}`);
|
| 286 |
+
resetView();
|
| 287 |
+
if (state.tasks.some((t) => t.id === rec.task)) $("task").value = rec.task;
|
| 288 |
+
state.follow = false;
|
| 289 |
+
rec.events.forEach((ev) => { if (ev.type !== "turn") ingest(ev); else { state.events.push(ev); state.turns.push(state.events.length - 1); } });
|
| 290 |
+
renderTimeline();
|
| 291 |
+
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 292 |
+
if (!state.turns.length) return;
|
| 293 |
+
if (reduce) { select(state.turns.length - 1, false); return; }
|
| 294 |
+
let i = 0;
|
| 295 |
+
select(0, false);
|
| 296 |
+
state.replayTimer = setInterval(() => {
|
| 297 |
+
if (++i >= state.turns.length) return stopReplay();
|
| 298 |
+
select(i, false);
|
| 299 |
+
}, 900);
|
| 300 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function stopReplay() {
|
| 304 |
+
if (state.replayTimer) clearInterval(state.replayTimer);
|
| 305 |
+
state.replayTimer = null;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
async function loadEpisodes() {
|
| 309 |
+
const list = await api("/api/episodes").catch(() => []);
|
| 310 |
+
const ul = $("episodes");
|
| 311 |
+
ul.replaceChildren();
|
| 312 |
+
if (!list.length) {
|
| 313 |
+
ul.innerHTML = '<li class="quiet">No finished runs yet. Start one above and it will appear here.</li>';
|
| 314 |
+
return;
|
| 315 |
+
}
|
| 316 |
+
list.forEach((ep) => {
|
| 317 |
+
const li = document.createElement("li");
|
| 318 |
+
const b = document.createElement("button");
|
| 319 |
+
b.type = "button";
|
| 320 |
+
const [cls, label] = OUTCOMES[ep.outcome] || ["", escapeHtml(ep.outcome)];
|
| 321 |
+
b.innerHTML = `<span>${escapeHtml(taskTitle(ep.task))}, layout ${ep.seed}${ep.use_lessons ? "" : ", no journal"}</span><span class="${cls}">${label}</span>`;
|
| 322 |
+
b.addEventListener("click", () => openReplay(ep.id));
|
| 323 |
+
li.appendChild(b);
|
| 324 |
+
ul.appendChild(li);
|
| 325 |
+
});
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
async function loadTasks() {
|
| 329 |
+
state.tasks = await api("/api/tasks");
|
| 330 |
+
const sel = $("task");
|
| 331 |
+
const current = sel.value;
|
| 332 |
+
sel.replaceChildren(...state.tasks.map((t) => {
|
| 333 |
+
const o = document.createElement("option");
|
| 334 |
+
o.value = t.id;
|
| 335 |
+
o.textContent = t.lessons ? `${t.title} (${t.lessons} lessons)` : t.title;
|
| 336 |
+
return o;
|
| 337 |
+
}));
|
| 338 |
+
if (current) sel.value = current;
|
| 339 |
+
if (!startEvent()) showGoal();
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
function showGoal() {
|
| 343 |
+
const t = state.tasks.find((x) => x.id === $("task").value);
|
| 344 |
+
if (t) $("goal").textContent = `${t.title}. ${t.goal}`;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
function escapeHtml(s) {
|
| 348 |
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
// โโ wiring โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 352 |
+
// The 3D module loads asynchronously; catch it up on whatever is already on screen.
|
| 353 |
+
window.addEventListener("lantern3d-ready", () => { if (state.lastFrame) window.Lantern3D.update(state.lastFrame); });
|
| 354 |
+
$("controls").addEventListener("submit", startRun);
|
| 355 |
+
$("stop").addEventListener("click", stopRun);
|
| 356 |
+
$("follow").addEventListener("click", () => select(state.turns.length - 1, true));
|
| 357 |
+
$("task").addEventListener("change", () => { if (!state.live && !startEvent()) showGoal(); });
|
| 358 |
+
$("timeline").addEventListener("keydown", (e) => {
|
| 359 |
+
if (e.key === "ArrowLeft") { stopReplay(); select(state.selected - 1, false); e.preventDefault(); }
|
| 360 |
+
if (e.key === "ArrowRight") { stopReplay(); select(state.selected + 1, state.live && state.selected + 1 === state.turns.length - 1); e.preventDefault(); }
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
(async () => {
|
| 364 |
+
await loadTasks();
|
| 365 |
+
await loadEpisodes();
|
| 366 |
+
const s = await api("/api/status").catch(() => null);
|
| 367 |
+
if (s && s.busy) $("status").textContent = "Beam is already exploring for another visitor. Open a replay or try again shortly.";
|
| 368 |
+
})();
|
web/assets/kenney-mini-dungeon/License.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
Mini Dungeon (2.0)
|
| 4 |
+
|
| 5 |
+
Created/distributed by Kenney (www.kenney.nl)
|
| 6 |
+
Creation date: 29-07-2026 10:35
|
| 7 |
+
|
| 8 |
+
------------------------------
|
| 9 |
+
|
| 10 |
+
License: (Creative Commons Zero, CC0)
|
| 11 |
+
http://creativecommons.org/publicdomain/zero/1.0/
|
| 12 |
+
|
| 13 |
+
You can use this content for personal, educational, and commercial purposes.
|
| 14 |
+
|
| 15 |
+
Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement)
|
| 16 |
+
|
| 17 |
+
------------------------------
|
| 18 |
+
|
| 19 |
+
โข Website : www.kenney.nl
|
| 20 |
+
โข Donate : www.kenney.nl/donate
|
| 21 |
+
|
| 22 |
+
โข Patreon : patreon.com/kenney
|
| 23 |
+
|
| 24 |
+
Follow on social media for updates:
|
| 25 |
+
|
| 26 |
+
โข Twitter: twitter.com/KenneyNL
|
| 27 |
+
โข BlueSky: kenney.bsky.social
|
| 28 |
+
โข Instagram: instagram.com/kenney_nl
|
web/assets/kenney-mini-dungeon/Textures/colormap.png
ADDED
|
web/assets/kenney-mini-dungeon/banner.glb
ADDED
|
Binary file (19.3 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/barrel.glb
ADDED
|
Binary file (14.1 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/character-human.glb
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6a149b22f52f5ea6b2983dd38cb164b112be66bad5a51d640d089aafd2647a7b
|
| 3 |
+
size 218072
|
web/assets/kenney-mini-dungeon/character-orc.glb
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e0b021c98b34a633567ca79f7c129cca48fd7919aa9e959464deba226767c284
|
| 3 |
+
size 199616
|
web/assets/kenney-mini-dungeon/chest.glb
ADDED
|
Binary file (34 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/coin.glb
ADDED
|
Binary file (22.1 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/column.glb
ADDED
|
Binary file (11.3 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/floor-detail.glb
ADDED
|
Binary file (11.9 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/floor.glb
ADDED
|
Binary file (1.8 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/gate.glb
ADDED
|
Binary file (14.2 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/key.glb
ADDED
|
Binary file (16.8 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/potion.glb
ADDED
|
Binary file (12.9 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/rocks.glb
ADDED
|
Binary file (18.5 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/stairs.glb
ADDED
|
Binary file (11.6 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/stones.glb
ADDED
|
Binary file (24.4 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/trap.glb
ADDED
|
Binary file (42.9 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/wall-opening.glb
ADDED
|
Binary file (19.2 kB). View file
|
|
|
web/assets/kenney-mini-dungeon/wall.glb
ADDED
|
Binary file (18.1 kB). View file
|
|
|
web/index.html
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Lantern</title>
|
| 7 |
+
<meta name="description" content="Watch the Beam model explore dungeon levels, think out loud, and learn from its own journal.">
|
| 8 |
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='%23f4b860' opacity='.25'/%3E%3Ctext x='16' y='22' font-size='18' text-anchor='middle' fill='%23f4b860' font-family='monospace'%3E@%3C/text%3E%3C/svg%3E">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
+
<link href="https://fonts.googleapis.com/css2?family=Martian+Mono:wght@400;600&family=Spectral:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
|
| 12 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<header class="bar">
|
| 16 |
+
<h1 class="wordmark">Lantern</h1>
|
| 17 |
+
<p class="tagline">Beam explores a dungeon it has never seen, one plan at a time.</p>
|
| 18 |
+
|
| 19 |
+
<form class="controls" id="controls">
|
| 20 |
+
<label class="field">
|
| 21 |
+
<span>Level</span>
|
| 22 |
+
<select id="task"></select>
|
| 23 |
+
</label>
|
| 24 |
+
<label class="field field-seed">
|
| 25 |
+
<span>Layout</span>
|
| 26 |
+
<input id="seed" type="number" min="0" max="1000000" value="0" inputmode="numeric">
|
| 27 |
+
</label>
|
| 28 |
+
<label class="toggle">
|
| 29 |
+
<input id="lessons" type="checkbox" checked>
|
| 30 |
+
<span>Use journal</span>
|
| 31 |
+
</label>
|
| 32 |
+
<button class="primary" id="start" type="submit">Watch Beam explore</button>
|
| 33 |
+
<button class="secondary" id="stop" type="button" hidden>Stop</button>
|
| 34 |
+
</form>
|
| 35 |
+
</header>
|
| 36 |
+
|
| 37 |
+
<main class="stage">
|
| 38 |
+
<section class="map-wrap" aria-label="Dungeon map">
|
| 39 |
+
<p class="goal" id="goal"></p>
|
| 40 |
+
<div class="viewport">
|
| 41 |
+
<div class="view3d" id="view3d" aria-hidden="true"></div>
|
| 42 |
+
<details class="beam-view" open>
|
| 43 |
+
<summary>What Beam sees</summary>
|
| 44 |
+
<div class="map" id="map" role="img" aria-label="ASCII dungeon map, exactly as Beam receives it"></div>
|
| 45 |
+
</details>
|
| 46 |
+
</div>
|
| 47 |
+
<p class="status" id="status" aria-live="polite">Pick a level and press <em>Watch Beam explore</em>, or open a replay below.</p>
|
| 48 |
+
</section>
|
| 49 |
+
|
| 50 |
+
<aside class="mind" aria-label="What Beam is thinking">
|
| 51 |
+
<h2 class="mind-title">Beam's thinking</h2>
|
| 52 |
+
<div class="reasoning" id="reasoning"><p class="quiet">Beam's reasoning for each move appears here.</p></div>
|
| 53 |
+
|
| 54 |
+
<h3 class="sub">Plan</h3>
|
| 55 |
+
<ol class="plan" id="plan"></ol>
|
| 56 |
+
|
| 57 |
+
<h3 class="sub">Notes to itself</h3>
|
| 58 |
+
<p class="notes" id="notes"><span class="quiet">Nothing yet.</span></p>
|
| 59 |
+
|
| 60 |
+
<details class="prompt">
|
| 61 |
+
<summary>Show exactly what Beam saw</summary>
|
| 62 |
+
<pre id="prompt"></pre>
|
| 63 |
+
</details>
|
| 64 |
+
</aside>
|
| 65 |
+
|
| 66 |
+
<section class="timeline-wrap" aria-label="Timeline">
|
| 67 |
+
<div class="timeline-head">
|
| 68 |
+
<h2 class="sub">Timeline</h2>
|
| 69 |
+
<button class="link" id="follow" type="button" hidden>Jump to live</button>
|
| 70 |
+
</div>
|
| 71 |
+
<div class="timeline" id="timeline" role="listbox" aria-label="Beam's calls, oldest first" tabindex="0"></div>
|
| 72 |
+
</section>
|
| 73 |
+
</main>
|
| 74 |
+
|
| 75 |
+
<section class="lower">
|
| 76 |
+
<article class="journal">
|
| 77 |
+
<h2>Journal</h2>
|
| 78 |
+
<p class="quiet" id="journal-intro">After each run Beam writes down what it learned. The next run on the same level starts by reading it.</p>
|
| 79 |
+
<div class="journal-cols">
|
| 80 |
+
<div>
|
| 81 |
+
<h3 class="sub">Read before this run</h3>
|
| 82 |
+
<ul id="lessons-in" class="lessons"></ul>
|
| 83 |
+
</div>
|
| 84 |
+
<div>
|
| 85 |
+
<h3 class="sub">Written after this run</h3>
|
| 86 |
+
<ul id="lessons-out" class="lessons"></ul>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</article>
|
| 90 |
+
|
| 91 |
+
<article class="replays">
|
| 92 |
+
<h2>Replays</h2>
|
| 93 |
+
<ul id="episodes" class="episodes"></ul>
|
| 94 |
+
</article>
|
| 95 |
+
</section>
|
| 96 |
+
|
| 97 |
+
<footer class="foot">
|
| 98 |
+
Levels from <a href="https://github.com/facebookresearch/minihack">MiniHack</a> (Meta AI) on the NetHack Learning Environment.
|
| 99 |
+
3D models by <a href="https://kenney.nl/assets/mini-dungeon">Kenney</a> (CC0).
|
| 100 |
+
Model: <a href="https://huggingface.co/reflection/Beam-1.0-501B-A23B">Beam 1.0</a> by Reflection.
|
| 101 |
+
Beam is an AI model and can make mistakes; runs are game play only.
|
| 102 |
+
</footer>
|
| 103 |
+
|
| 104 |
+
<script type="importmap">
|
| 105 |
+
{ "imports": {
|
| 106 |
+
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
| 107 |
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
| 108 |
+
} }
|
| 109 |
+
</script>
|
| 110 |
+
<script type="module" src="/static/scene3d.js"></script>
|
| 111 |
+
<script src="/static/app.js"></script>
|
| 112 |
+
</body>
|
| 113 |
+
</html>
|
web/scene3d.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Lantern 3D view: the dungeon Beam is exploring, lit only by the lantern it carries.
|
| 2 |
+
// Models: Kenney "Mini Dungeon" (CC0). Exposes window.Lantern3D for app.js.
|
| 3 |
+
import * as THREE from "three";
|
| 4 |
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
| 5 |
+
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";
|
| 6 |
+
|
| 7 |
+
const ASSET_DIR = "/static/assets/kenney-mini-dungeon/";
|
| 8 |
+
const MODEL_NAMES = ["floor", "floor-detail", "wall", "stairs", "key", "potion", "trap", "chest",
|
| 9 |
+
"gate", "rocks", "character-human", "character-orc", "coin", "column"];
|
| 10 |
+
const WALKABLE = new Set(["floor", "doorway", "door_open", "door_closed", "stairs_down", "stairs_up",
|
| 11 |
+
"item", "monster", "hero", "trap", "boulder", "feature", "tree", "cloud"]);
|
| 12 |
+
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 13 |
+
|
| 14 |
+
function webglAvailable() {
|
| 15 |
+
try {
|
| 16 |
+
const c = document.createElement("canvas");
|
| 17 |
+
return !!(window.WebGLRenderingContext && (c.getContext("webgl2") || c.getContext("webgl")));
|
| 18 |
+
} catch { return false; }
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
class DungeonScene {
|
| 22 |
+
constructor(container) {
|
| 23 |
+
this.container = container;
|
| 24 |
+
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
| 25 |
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
| 26 |
+
this.renderer.shadowMap.enabled = true;
|
| 27 |
+
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
| 28 |
+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
| 29 |
+
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
| 30 |
+
container.appendChild(this.renderer.domElement);
|
| 31 |
+
|
| 32 |
+
this.scene = new THREE.Scene();
|
| 33 |
+
this.scene.background = new THREE.Color("#0e1118");
|
| 34 |
+
this.scene.fog = new THREE.Fog("#0e1118", 10, 26);
|
| 35 |
+
|
| 36 |
+
this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
|
| 37 |
+
this.camOffset = new THREE.Vector3(0, 12.5, 9.5);
|
| 38 |
+
this.camTarget = new THREE.Vector3();
|
| 39 |
+
|
| 40 |
+
// Moonlight: just enough to remember explored rooms. The lantern does the real lighting.
|
| 41 |
+
this.scene.add(new THREE.HemisphereLight("#5b6f9e", "#0b0d12", 0.55));
|
| 42 |
+
this.lantern = new THREE.PointLight("#f4b860", 16, 10, 1.4);
|
| 43 |
+
this.lantern.castShadow = true;
|
| 44 |
+
this.lantern.shadow.mapSize.set(1024, 1024);
|
| 45 |
+
this.lantern.shadow.bias = -0.004;
|
| 46 |
+
this.scene.add(this.lantern);
|
| 47 |
+
|
| 48 |
+
this.world = new THREE.Group();
|
| 49 |
+
this.scene.add(this.world);
|
| 50 |
+
this.cells = new Map(); // "x,y" -> { kind, obj }
|
| 51 |
+
this.actors = new THREE.Group();
|
| 52 |
+
this.scene.add(this.actors);
|
| 53 |
+
this.models = {};
|
| 54 |
+
this.clock = new THREE.Clock();
|
| 55 |
+
this.lavaMats = [];
|
| 56 |
+
this.heroTarget = null;
|
| 57 |
+
|
| 58 |
+
new ResizeObserver(() => this.resize()).observe(container);
|
| 59 |
+
this.resize();
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
async load() {
|
| 63 |
+
const loader = new GLTFLoader();
|
| 64 |
+
const loaded = await Promise.all(MODEL_NAMES.map((n) => loader.loadAsync(`${ASSET_DIR}${n}.glb`)));
|
| 65 |
+
loaded.forEach((gltf, i) => {
|
| 66 |
+
gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
|
| 67 |
+
this.models[MODEL_NAMES[i]] = gltf;
|
| 68 |
+
});
|
| 69 |
+
this.makeMaterials();
|
| 70 |
+
this.makeHero();
|
| 71 |
+
this.renderer.setAnimationLoop(() => this.tick());
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
makeMaterials() {
|
| 75 |
+
this.mats = {
|
| 76 |
+
// A faint glow of its own so water reads as water, not a hole, outside the lamplight.
|
| 77 |
+
water: new THREE.MeshStandardMaterial({ color: "#2a6699", emissive: "#123a5c", emissiveIntensity: 0.9, roughness: 0.12, metalness: 0.25 }),
|
| 78 |
+
lava: new THREE.MeshStandardMaterial({ color: "#5a1a05", emissive: "#ff6a1a", emissiveIntensity: 1.4, roughness: 0.6 }),
|
| 79 |
+
ice: new THREE.MeshStandardMaterial({ color: "#bfe3f2", roughness: 0.1, metalness: 0.1 }),
|
| 80 |
+
leaves: new THREE.MeshStandardMaterial({ color: "#3f7a4a", roughness: 0.9, flatShading: true }),
|
| 81 |
+
trunk: new THREE.MeshStandardMaterial({ color: "#5a3d26", roughness: 1 }),
|
| 82 |
+
cloud: new THREE.MeshStandardMaterial({ color: "#d9dde6", transparent: true, opacity: 0.55, roughness: 1 }),
|
| 83 |
+
beacon: new THREE.MeshBasicMaterial({ color: "#f4b860", transparent: true, opacity: 0.55 }),
|
| 84 |
+
};
|
| 85 |
+
this.lavaMats.push(this.mats.lava);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
makeHero() {
|
| 89 |
+
const gltf = this.models["character-human"];
|
| 90 |
+
this.hero = SkeletonUtils.clone(gltf.scene);
|
| 91 |
+
this.hero.scale.setScalar(1.6);
|
| 92 |
+
this.mixer = new THREE.AnimationMixer(this.hero);
|
| 93 |
+
const clip = (name) => THREE.AnimationClip.findByName(gltf.animations, name);
|
| 94 |
+
this.anims = { idle: this.mixer.clipAction(clip("idle")), walk: this.mixer.clipAction(clip("walk")) };
|
| 95 |
+
this.anims.idle.play();
|
| 96 |
+
this.current = "idle";
|
| 97 |
+
this.hero.visible = false;
|
| 98 |
+
this.scene.add(this.hero);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
clone(name, scale = 1) {
|
| 102 |
+
const obj = SkeletonUtils.clone(this.models[name].scene);
|
| 103 |
+
obj.scale.setScalar(scale);
|
| 104 |
+
return obj;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// โโ building cells โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 108 |
+
buildCell(kind, x, y, neighbors, label) {
|
| 109 |
+
const g = new THREE.Group();
|
| 110 |
+
g.position.set(x, 0, y);
|
| 111 |
+
const plane = (mat, h = -0.02) => {
|
| 112 |
+
const m = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
|
| 113 |
+
m.rotation.x = -Math.PI / 2; m.position.y = h; m.receiveShadow = true; return m;
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
if (kind === "wall") { g.add(this.clone("wall")); return g; }
|
| 117 |
+
if (kind === "corridor") { g.add(this.clone("floor-detail")); return g; }
|
| 118 |
+
if (kind === "water") { g.add(plane(this.mats.water, -0.08)); return g; }
|
| 119 |
+
if (kind === "lava") { g.add(plane(this.mats.lava, -0.06)); return g; }
|
| 120 |
+
if (kind === "ice") { g.add(plane(this.mats.ice)); return g; }
|
| 121 |
+
if (!WALKABLE.has(kind)) return null;
|
| 122 |
+
|
| 123 |
+
g.add(this.clone("floor"));
|
| 124 |
+
if (kind === "door_closed" || kind === "door_open") {
|
| 125 |
+
const gate = this.clone("gate");
|
| 126 |
+
// Doors sit in a wall line; face the gate across the gap.
|
| 127 |
+
const verticalWall = neighbors.n === "wall" || neighbors.s === "wall";
|
| 128 |
+
gate.rotation.y = verticalWall ? Math.PI / 2 : 0;
|
| 129 |
+
if (kind === "door_open") { gate.rotation.y += Math.PI / 2; gate.position.x += verticalWall ? 0 : -0.35; }
|
| 130 |
+
g.add(gate);
|
| 131 |
+
} else if (kind === "stairs_down" || kind === "stairs_up") {
|
| 132 |
+
g.add(this.clone("stairs"));
|
| 133 |
+
if (kind === "stairs_down") {
|
| 134 |
+
const ring = new THREE.Mesh(new THREE.RingGeometry(0.38, 0.48, 40), this.mats.beacon);
|
| 135 |
+
ring.rotation.x = -Math.PI / 2; ring.position.y = 0.02; ring.userData.beacon = true;
|
| 136 |
+
g.add(ring);
|
| 137 |
+
}
|
| 138 |
+
} else if (kind === "trap") {
|
| 139 |
+
g.add(this.clone("trap"));
|
| 140 |
+
} else if (kind === "boulder") {
|
| 141 |
+
g.add(this.clone("rocks", 1.5));
|
| 142 |
+
} else if (kind === "feature") {
|
| 143 |
+
g.add(this.clone("column"));
|
| 144 |
+
} else if (kind === "tree") {
|
| 145 |
+
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, 0.4, 6), this.mats.trunk);
|
| 146 |
+
trunk.position.y = 0.2;
|
| 147 |
+
const crown = new THREE.Mesh(new THREE.ConeGeometry(0.36, 0.9, 7), this.mats.leaves);
|
| 148 |
+
crown.position.y = 0.8;
|
| 149 |
+
[trunk, crown].forEach((m) => { m.castShadow = true; g.add(m); });
|
| 150 |
+
} else if (kind === "cloud") {
|
| 151 |
+
[[0, 0.5, 0, 0.32], [0.2, 0.6, 0.1, 0.24], [-0.2, 0.55, -0.1, 0.26]].forEach(([px, py, pz, r]) => {
|
| 152 |
+
const s = new THREE.Mesh(new THREE.SphereGeometry(r, 10, 8), this.mats.cloud);
|
| 153 |
+
s.position.set(px, py, pz); g.add(s);
|
| 154 |
+
});
|
| 155 |
+
} else if (kind === "item") {
|
| 156 |
+
const name = (label || "").toLowerCase();
|
| 157 |
+
const model = name.includes("key") ? "key" : name.includes("potion") ? "potion"
|
| 158 |
+
: name.includes("ring") || name.includes("coin") || name.includes("gold") ? "coin" : "chest";
|
| 159 |
+
const item = this.clone(model, model === "chest" ? 0.7 : 1.4);
|
| 160 |
+
item.userData.bob = true;
|
| 161 |
+
g.add(item);
|
| 162 |
+
}
|
| 163 |
+
return g;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
makeLabel(text) {
|
| 167 |
+
const canvas = document.createElement("canvas");
|
| 168 |
+
const ctx = canvas.getContext("2d");
|
| 169 |
+
const font = "600 44px Spectral, Georgia, serif";
|
| 170 |
+
ctx.font = font;
|
| 171 |
+
canvas.width = Math.ceil(ctx.measureText(text).width) + 36;
|
| 172 |
+
canvas.height = 64;
|
| 173 |
+
ctx.font = font;
|
| 174 |
+
ctx.fillStyle = "rgba(14,17,24,0.78)";
|
| 175 |
+
ctx.beginPath(); ctx.roundRect(0, 0, canvas.width, canvas.height, 14); ctx.fill();
|
| 176 |
+
ctx.fillStyle = "#e9e3d3";
|
| 177 |
+
ctx.textBaseline = "middle";
|
| 178 |
+
ctx.fillText(text, 18, 34);
|
| 179 |
+
const tex = new THREE.CanvasTexture(canvas);
|
| 180 |
+
tex.colorSpace = THREE.SRGBColorSpace;
|
| 181 |
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }));
|
| 182 |
+
sprite.scale.set(canvas.width / canvas.height * 0.32, 0.32, 1);
|
| 183 |
+
sprite.renderOrder = 10;
|
| 184 |
+
return sprite;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
// โโ per-frame update from the game โโโโโโโโโโโโโ
|
| 188 |
+
update(frame) {
|
| 189 |
+
if (!frame || !frame.kinds || !frame.kinds.length) return;
|
| 190 |
+
const [top, left] = frame.origin;
|
| 191 |
+
const kindAt = (r, c) => (frame.kinds[r] && frame.kinds[r][c]) || "unknown";
|
| 192 |
+
const labels = new Map(frame.labels.map(([r, c, name]) => [`${r},${c}`, name]));
|
| 193 |
+
const seen = new Set();
|
| 194 |
+
|
| 195 |
+
frame.kinds.forEach((row, r) => row.forEach((kind, c) => {
|
| 196 |
+
const x = c + left, y = r + top, key = `${x},${y}`;
|
| 197 |
+
// Actors are drawn separately; the tile under them is floor.
|
| 198 |
+
const tile = kind === "hero" || kind === "monster" ? "floor" : kind;
|
| 199 |
+
const label = kind === "item" ? labels.get(`${r},${c}`) : "";
|
| 200 |
+
const sig = `${tile}|${label || ""}`;
|
| 201 |
+
seen.add(key);
|
| 202 |
+
const prev = this.cells.get(key);
|
| 203 |
+
if (prev && prev.sig === sig) return;
|
| 204 |
+
if (prev && prev.obj) this.world.remove(prev.obj);
|
| 205 |
+
const neighbors = { n: kindAt(r - 1, c), s: kindAt(r + 1, c), e: kindAt(r, c + 1), w: kindAt(r, c - 1) };
|
| 206 |
+
const obj = this.buildCell(tile, x, y, neighbors, label);
|
| 207 |
+
if (obj) this.world.add(obj);
|
| 208 |
+
this.cells.set(key, { sig, obj });
|
| 209 |
+
}));
|
| 210 |
+
for (const [key, cell] of this.cells) {
|
| 211 |
+
if (!seen.has(key)) { if (cell.obj) this.world.remove(cell.obj); this.cells.delete(key); }
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Monsters: few per level, so rebuild each frame.
|
| 215 |
+
this.actors.clear();
|
| 216 |
+
frame.kinds.forEach((row, r) => row.forEach((kind, c) => {
|
| 217 |
+
if (kind !== "monster") return;
|
| 218 |
+
const m = this.clone("character-orc", 1.5);
|
| 219 |
+
m.position.set(c + left, 0, r + top);
|
| 220 |
+
m.lookAt(frame.status.x, 0, frame.status.y);
|
| 221 |
+
this.actors.add(m);
|
| 222 |
+
const name = labels.get(`${r},${c}`);
|
| 223 |
+
if (name) { const s = this.makeLabel(name); s.position.set(c + left, 1.05, r + top); this.actors.add(s); }
|
| 224 |
+
}));
|
| 225 |
+
|
| 226 |
+
// Frame the camera on the explored area; large maps lean toward following Beam.
|
| 227 |
+
const w = frame.kinds[0].length, h = frame.kinds.length;
|
| 228 |
+
this.levelCenter = new THREE.Vector3(left + (w - 1) / 2, 0, top + (h - 1) / 2);
|
| 229 |
+
this.follow = w > 26 || h > 14 ? 0.8 : 0.3;
|
| 230 |
+
this.zoom = THREE.MathUtils.clamp(Math.max(w / 18, h / 10), 0.85, 1.35);
|
| 231 |
+
|
| 232 |
+
const target = new THREE.Vector3(frame.status.x, 0, frame.status.y);
|
| 233 |
+
if (!this.hero.visible || reduceMotion || target.distanceTo(this.hero.position) > 4) {
|
| 234 |
+
this.hero.position.copy(target);
|
| 235 |
+
this.camTarget.copy(target);
|
| 236 |
+
}
|
| 237 |
+
this.hero.visible = true;
|
| 238 |
+
this.heroTarget = target;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
play(name) {
|
| 242 |
+
if (this.current === name) return;
|
| 243 |
+
this.anims[this.current].fadeOut(0.15);
|
| 244 |
+
this.anims[name].reset().fadeIn(0.15).play();
|
| 245 |
+
this.current = name;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
tick() {
|
| 249 |
+
const dt = Math.min(this.clock.getDelta(), 0.05);
|
| 250 |
+
const t = this.clock.elapsedTime;
|
| 251 |
+
if (this.heroTarget) {
|
| 252 |
+
const toTarget = this.heroTarget.clone().sub(this.hero.position);
|
| 253 |
+
const dist = toTarget.length();
|
| 254 |
+
if (dist > 0.02) {
|
| 255 |
+
this.hero.position.addScaledVector(toTarget.normalize(), Math.min(dist, dt * 4.5));
|
| 256 |
+
this.hero.rotation.y = Math.atan2(toTarget.x, toTarget.z);
|
| 257 |
+
this.play("walk");
|
| 258 |
+
} else {
|
| 259 |
+
this.hero.position.copy(this.heroTarget);
|
| 260 |
+
this.play("idle");
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
this.mixer && this.mixer.update(dt);
|
| 264 |
+
|
| 265 |
+
const flicker = reduceMotion ? 0 : Math.sin(t * 11) * 0.5 + Math.sin(t * 17.3) * 0.35;
|
| 266 |
+
this.lantern.intensity = 16 + flicker;
|
| 267 |
+
this.lantern.position.set(this.hero.position.x + 0.25, 1.9, this.hero.position.z + 0.25);
|
| 268 |
+
const focus = this.levelCenter
|
| 269 |
+
? this.levelCenter.clone().lerp(this.hero.position, this.follow) : this.hero.position;
|
| 270 |
+
this.camTarget.lerp(focus, reduceMotion ? 1 : Math.min(1, dt * 3));
|
| 271 |
+
this.camera.position.copy(this.camTarget).addScaledVector(this.camOffset, this.zoom || 1);
|
| 272 |
+
this.camera.lookAt(this.camTarget);
|
| 273 |
+
|
| 274 |
+
if (!reduceMotion) {
|
| 275 |
+
this.lavaMats.forEach((m) => { m.emissiveIntensity = 1.25 + Math.sin(t * 2.2) * 0.25; });
|
| 276 |
+
this.world.traverse((o) => {
|
| 277 |
+
if (o.userData.beacon) o.material.opacity = 0.35 + Math.sin(t * 3) * 0.2;
|
| 278 |
+
if (o.userData.bob) o.position.y = 0.06 + Math.sin(t * 2.5 + o.parent.position.x) * 0.04;
|
| 279 |
+
});
|
| 280 |
+
}
|
| 281 |
+
this.renderer.render(this.scene, this.camera);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
resize() {
|
| 285 |
+
const w = this.container.clientWidth, h = this.container.clientHeight;
|
| 286 |
+
if (!w || !h) return;
|
| 287 |
+
this.renderer.setSize(w, h, false);
|
| 288 |
+
this.camera.aspect = w / h;
|
| 289 |
+
this.camera.updateProjectionMatrix();
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
async function boot() {
|
| 294 |
+
const container = document.getElementById("view3d");
|
| 295 |
+
if (!container || !webglAvailable()) return;
|
| 296 |
+
try {
|
| 297 |
+
const view = new DungeonScene(container);
|
| 298 |
+
await view.load();
|
| 299 |
+
window.Lantern3D = view;
|
| 300 |
+
document.body.classList.add("has-3d");
|
| 301 |
+
window.dispatchEvent(new Event("lantern3d-ready"));
|
| 302 |
+
} catch (err) {
|
| 303 |
+
console.warn("3D view unavailable, using the text map.", err);
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
boot();
|
web/style.css
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--cave: #1a1f2b;
|
| 3 |
+
--stone: #262d3b;
|
| 4 |
+
--stone-edge: #343c4d;
|
| 5 |
+
--lamp: #f4b860;
|
| 6 |
+
--ink: #e9e3d3;
|
| 7 |
+
--muted: #8a93a6;
|
| 8 |
+
--moss: #86b37e;
|
| 9 |
+
--blood: #c24e5a;
|
| 10 |
+
--mono: "Martian Mono", ui-monospace, monospace;
|
| 11 |
+
--serif: "Spectral", Georgia, serif;
|
| 12 |
+
--cell: clamp(11px, 1.55vw, 22px);
|
| 13 |
+
color-scheme: dark;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
* { box-sizing: border-box; }
|
| 17 |
+
|
| 18 |
+
body {
|
| 19 |
+
margin: 0;
|
| 20 |
+
background: var(--cave);
|
| 21 |
+
color: var(--ink);
|
| 22 |
+
font: 400 17px/1.55 var(--serif);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
a { color: var(--lamp); }
|
| 26 |
+
:focus-visible { outline: 2px solid var(--lamp); outline-offset: 2px; }
|
| 27 |
+
|
| 28 |
+
/* โโ header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 29 |
+
.bar {
|
| 30 |
+
display: grid;
|
| 31 |
+
grid-template-columns: auto 1fr;
|
| 32 |
+
gap: 4px 20px;
|
| 33 |
+
align-items: baseline;
|
| 34 |
+
padding: 20px clamp(16px, 3vw, 40px) 16px;
|
| 35 |
+
}
|
| 36 |
+
.wordmark {
|
| 37 |
+
margin: 0;
|
| 38 |
+
font: italic 600 2.1rem/1 var(--serif);
|
| 39 |
+
color: var(--lamp);
|
| 40 |
+
text-shadow: 0 0 18px rgb(244 184 96 / 0.45);
|
| 41 |
+
}
|
| 42 |
+
.tagline { margin: 0; color: var(--muted); }
|
| 43 |
+
.controls {
|
| 44 |
+
grid-column: 1 / -1;
|
| 45 |
+
display: flex;
|
| 46 |
+
flex-wrap: wrap;
|
| 47 |
+
gap: 12px 16px;
|
| 48 |
+
align-items: end;
|
| 49 |
+
margin-top: 14px;
|
| 50 |
+
}
|
| 51 |
+
.field { display: grid; gap: 4px; font-size: 0.85rem; color: var(--muted); }
|
| 52 |
+
.field select, .field input {
|
| 53 |
+
font: 400 0.95rem var(--serif);
|
| 54 |
+
color: var(--ink);
|
| 55 |
+
background: var(--stone);
|
| 56 |
+
border: 1px solid var(--stone-edge);
|
| 57 |
+
border-radius: 6px;
|
| 58 |
+
padding: 7px 10px;
|
| 59 |
+
min-width: 200px;
|
| 60 |
+
}
|
| 61 |
+
.field-seed input { min-width: 0; width: 7.5em; }
|
| 62 |
+
.toggle { display: flex; gap: 8px; align-items: center; padding-bottom: 8px; color: var(--muted); }
|
| 63 |
+
.toggle input { accent-color: var(--lamp); width: 16px; height: 16px; }
|
| 64 |
+
|
| 65 |
+
button { font: 600 0.95rem var(--serif); cursor: pointer; border-radius: 6px; }
|
| 66 |
+
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 67 |
+
.primary {
|
| 68 |
+
background: var(--lamp);
|
| 69 |
+
color: #2a1d08;
|
| 70 |
+
border: 0;
|
| 71 |
+
padding: 9px 18px;
|
| 72 |
+
box-shadow: 0 0 0 0 rgb(244 184 96 / 0.4);
|
| 73 |
+
}
|
| 74 |
+
.primary:not(:disabled):hover { box-shadow: 0 0 22px 2px rgb(244 184 96 / 0.35); }
|
| 75 |
+
.secondary { background: transparent; color: var(--ink); border: 1px solid var(--stone-edge); padding: 8px 16px; }
|
| 76 |
+
.link { background: none; border: 0; color: var(--lamp); padding: 0; text-decoration: underline; }
|
| 77 |
+
|
| 78 |
+
/* โโ stage: map + mind + timeline โโโโโโโโโโโโโโโโโโโ */
|
| 79 |
+
.stage {
|
| 80 |
+
display: grid;
|
| 81 |
+
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr);
|
| 82 |
+
grid-template-areas: "map mind" "time time";
|
| 83 |
+
gap: 0;
|
| 84 |
+
margin: 0 clamp(16px, 3vw, 40px);
|
| 85 |
+
border: 1px solid var(--stone-edge);
|
| 86 |
+
border-radius: 10px;
|
| 87 |
+
overflow: hidden;
|
| 88 |
+
background: #151923;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.map-wrap { grid-area: map; padding: 18px 20px 14px; display: flex; flex-direction: column; min-height: 420px; }
|
| 92 |
+
.goal { margin: 0 0 12px; color: var(--muted); max-width: 70ch; }
|
| 93 |
+
|
| 94 |
+
.viewport { flex: 1; position: relative; display: flex; min-height: 440px; }
|
| 95 |
+
.view3d { display: none; }
|
| 96 |
+
.has-3d .view3d {
|
| 97 |
+
display: block;
|
| 98 |
+
position: absolute;
|
| 99 |
+
inset: 0;
|
| 100 |
+
border-radius: 8px;
|
| 101 |
+
overflow: hidden;
|
| 102 |
+
}
|
| 103 |
+
.view3d canvas { width: 100%; height: 100%; display: block; }
|
| 104 |
+
|
| 105 |
+
/* Without 3D the text map is the main view; with 3D it becomes a corner inset. */
|
| 106 |
+
.beam-view { flex: 1; display: flex; flex-direction: column; }
|
| 107 |
+
.beam-view summary { display: none; }
|
| 108 |
+
.has-3d .beam-view {
|
| 109 |
+
flex: none;
|
| 110 |
+
position: absolute;
|
| 111 |
+
right: 12px;
|
| 112 |
+
bottom: 12px;
|
| 113 |
+
max-width: min(46%, 420px);
|
| 114 |
+
background: rgb(21 25 35 / 0.86);
|
| 115 |
+
border: 1px solid var(--stone-edge);
|
| 116 |
+
border-radius: 8px;
|
| 117 |
+
padding: 8px 10px 10px;
|
| 118 |
+
backdrop-filter: blur(3px);
|
| 119 |
+
}
|
| 120 |
+
.has-3d .beam-view summary { display: list-item; cursor: pointer; font-size: 0.85rem; color: var(--muted); }
|
| 121 |
+
.has-3d .beam-view[open] summary { margin-bottom: 6px; }
|
| 122 |
+
.has-3d .beam-view .map { --cell: 8px; overflow: hidden; place-content: start; }
|
| 123 |
+
|
| 124 |
+
.map {
|
| 125 |
+
flex: 1;
|
| 126 |
+
display: grid;
|
| 127 |
+
place-content: center;
|
| 128 |
+
position: relative;
|
| 129 |
+
font: 400 var(--cell)/1.2 var(--mono);
|
| 130 |
+
white-space: pre;
|
| 131 |
+
user-select: none;
|
| 132 |
+
}
|
| 133 |
+
/* The lantern: a warm pool of light centred on Beam, positioned by app.js. */
|
| 134 |
+
.map::before {
|
| 135 |
+
content: "";
|
| 136 |
+
position: absolute;
|
| 137 |
+
left: var(--hx, 50%);
|
| 138 |
+
top: var(--hy, 50%);
|
| 139 |
+
width: calc(var(--cell) * 16);
|
| 140 |
+
height: calc(var(--cell) * 16);
|
| 141 |
+
transform: translate(-50%, -50%);
|
| 142 |
+
background: radial-gradient(closest-side, rgb(244 184 96 / 0.22), rgb(244 184 96 / 0.07) 60%, transparent);
|
| 143 |
+
pointer-events: none;
|
| 144 |
+
transition: left 0.25s ease, top 0.25s ease;
|
| 145 |
+
}
|
| 146 |
+
.map .row { position: relative; height: calc(var(--cell) * 1.2); }
|
| 147 |
+
.map span { display: inline-block; width: 1ch; text-align: center; }
|
| 148 |
+
.map .hero { color: var(--lamp); font-weight: 600; text-shadow: 0 0 10px var(--lamp); }
|
| 149 |
+
/* Where Beam has walked: floor glyphs take on a faint lamplight tint. */
|
| 150 |
+
.map .trail { color: #b5874a; }
|
| 151 |
+
|
| 152 |
+
.status { margin: 12px 0 0; font-size: 0.95rem; color: var(--muted); }
|
| 153 |
+
.status strong { color: var(--ink); font-weight: 600; }
|
| 154 |
+
.status .win { color: var(--moss); }
|
| 155 |
+
.status .loss { color: var(--blood); }
|
| 156 |
+
|
| 157 |
+
.mind {
|
| 158 |
+
grid-area: mind;
|
| 159 |
+
border-left: 1px solid var(--stone-edge);
|
| 160 |
+
background: var(--stone);
|
| 161 |
+
padding: 18px 20px;
|
| 162 |
+
display: flex;
|
| 163 |
+
flex-direction: column;
|
| 164 |
+
min-height: 0;
|
| 165 |
+
max-height: 640px;
|
| 166 |
+
}
|
| 167 |
+
.mind-title { margin: 0 0 8px; font: italic 600 1.25rem var(--serif); }
|
| 168 |
+
.reasoning {
|
| 169 |
+
flex: 1 1 auto;
|
| 170 |
+
overflow-y: auto;
|
| 171 |
+
min-height: 120px;
|
| 172 |
+
padding-right: 6px;
|
| 173 |
+
font-style: italic;
|
| 174 |
+
line-height: 1.65;
|
| 175 |
+
white-space: pre-wrap;
|
| 176 |
+
}
|
| 177 |
+
.reasoning p { margin: 0 0 0.6em; }
|
| 178 |
+
.sub { margin: 14px 0 6px; font: 600 0.9rem var(--serif); color: var(--muted); }
|
| 179 |
+
|
| 180 |
+
.plan { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|
| 181 |
+
.plan li {
|
| 182 |
+
font: 400 0.8rem var(--mono);
|
| 183 |
+
padding: 3px 8px;
|
| 184 |
+
border-radius: 4px;
|
| 185 |
+
border: 1px solid var(--lamp);
|
| 186 |
+
color: var(--lamp);
|
| 187 |
+
}
|
| 188 |
+
.plan li.skipped { border-style: dashed; border-color: var(--muted); color: var(--muted); text-decoration: line-through; }
|
| 189 |
+
.plan .why { border: 0; padding: 3px 0; color: var(--muted); font-family: var(--serif); font-style: italic; }
|
| 190 |
+
|
| 191 |
+
.notes { margin: 0; font-size: 0.95rem; white-space: pre-wrap; max-height: 9em; overflow-y: auto; }
|
| 192 |
+
|
| 193 |
+
.prompt { margin-top: 14px; font-size: 0.85rem; color: var(--muted); }
|
| 194 |
+
.prompt summary { cursor: pointer; }
|
| 195 |
+
.prompt pre { font: 400 0.72rem/1.35 var(--mono); max-height: 260px; overflow: auto; background: var(--cave); padding: 10px; border-radius: 6px; color: var(--ink); }
|
| 196 |
+
|
| 197 |
+
.timeline-wrap { grid-area: time; border-top: 1px solid var(--stone-edge); padding: 10px 20px 14px; }
|
| 198 |
+
.timeline-head { display: flex; justify-content: space-between; align-items: baseline; }
|
| 199 |
+
.timeline-head .sub { margin: 0 0 8px; }
|
| 200 |
+
.timeline {
|
| 201 |
+
display: flex;
|
| 202 |
+
align-items: flex-end;
|
| 203 |
+
gap: 3px;
|
| 204 |
+
height: 46px;
|
| 205 |
+
overflow-x: auto;
|
| 206 |
+
scrollbar-width: thin;
|
| 207 |
+
}
|
| 208 |
+
.tick {
|
| 209 |
+
flex: 0 0 9px;
|
| 210 |
+
min-height: 6px;
|
| 211 |
+
border: 0;
|
| 212 |
+
border-radius: 2px 2px 0 0;
|
| 213 |
+
background: #4a5367;
|
| 214 |
+
padding: 0;
|
| 215 |
+
}
|
| 216 |
+
.tick.act { background: #9a7a48; }
|
| 217 |
+
.tick.interrupted { background: var(--lamp); }
|
| 218 |
+
.tick.win { background: var(--moss); }
|
| 219 |
+
.tick.loss { background: var(--blood); }
|
| 220 |
+
.tick[aria-selected="true"] { outline: 2px solid var(--ink); outline-offset: 1px; }
|
| 221 |
+
|
| 222 |
+
/* โโ journal + replays โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
|
| 223 |
+
.lower {
|
| 224 |
+
display: grid;
|
| 225 |
+
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 1fr);
|
| 226 |
+
gap: 28px;
|
| 227 |
+
margin: 28px clamp(16px, 3vw, 40px);
|
| 228 |
+
}
|
| 229 |
+
.lower h2 { margin: 0 0 6px; font: italic 600 1.25rem var(--serif); }
|
| 230 |
+
.journal-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
| 231 |
+
.lessons { margin: 0; padding-left: 1.1em; max-width: 60ch; }
|
| 232 |
+
.lessons li { margin-bottom: 0.5em; }
|
| 233 |
+
.lessons li.new::marker { color: var(--lamp); }
|
| 234 |
+
.episodes { list-style: none; margin: 0; padding: 0; }
|
| 235 |
+
.episodes button {
|
| 236 |
+
width: 100%;
|
| 237 |
+
display: flex;
|
| 238 |
+
justify-content: space-between;
|
| 239 |
+
gap: 12px;
|
| 240 |
+
text-align: left;
|
| 241 |
+
background: none;
|
| 242 |
+
border: 0;
|
| 243 |
+
border-bottom: 1px solid var(--stone-edge);
|
| 244 |
+
border-radius: 0;
|
| 245 |
+
color: var(--ink);
|
| 246 |
+
padding: 8px 2px;
|
| 247 |
+
font-weight: 400;
|
| 248 |
+
}
|
| 249 |
+
.episodes button:hover { color: var(--lamp); }
|
| 250 |
+
.episodes .win { color: var(--moss); }
|
| 251 |
+
.episodes .loss { color: var(--blood); }
|
| 252 |
+
.quiet { color: var(--muted); }
|
| 253 |
+
|
| 254 |
+
.foot { margin: 0 clamp(16px, 3vw, 40px) 28px; font-size: 0.85rem; color: var(--muted); max-width: 90ch; }
|
| 255 |
+
|
| 256 |
+
@media (max-width: 860px) {
|
| 257 |
+
.bar { grid-template-columns: 1fr; }
|
| 258 |
+
.stage { grid-template-columns: 1fr; grid-template-areas: "map" "mind" "time"; }
|
| 259 |
+
.mind { border-left: 0; border-top: 1px solid var(--stone-edge); max-height: none; }
|
| 260 |
+
.map { overflow-x: auto; place-content: start; }
|
| 261 |
+
.viewport { flex-direction: column; min-height: 0; }
|
| 262 |
+
.has-3d .view3d { position: relative; inset: auto; height: 340px; }
|
| 263 |
+
.has-3d .beam-view { position: static; max-width: none; margin-top: 12px; }
|
| 264 |
+
.lower, .journal-cols { grid-template-columns: 1fr; }
|
| 265 |
+
.field select { min-width: 0; }
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
@media (prefers-reduced-motion: reduce) {
|
| 269 |
+
.map::before { transition: none; }
|
| 270 |
+
}
|