visproj commited on
Commit
43ffbf2
ยท
verified ยท
1 Parent(s): 3fdfe2c

feat: initial Lantern Space (agent loop, 3D viewer, live runs)

Browse files
.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: blue
5
- colorTo: gray
6
  sdk: docker
7
- pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("&ensp;");
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
+ }