5f34f56839ca12659cc8547e7afe3a35df51732c

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Rework into LLM-driven text adventure

- Replace grid map + per-location personas with one scenario seed; the LLM owns world state through a single game-loop context.
- Unified 6-field reply contract (LOCATION/TALK/DESC + all-or-nothing spoken triple); empty-tolerant parser.
- Segment-based session log with per-location compaction and a budget safety net.
- Action-turn + spoken-turn orchestration (judge parallel with game LLM, then TTS).
- UI: scrolling adventure log + input line; F2 push-to-talk, F3/F4 staged reveals, Esc.
- Drop --map/--persona-dir flags for --scenario; remove dead map/persona code and data assets.

Diff

This diff is truncated to protect this page.

   1diff --git a/AGENTS.md b/AGENTS.md
   2index 4d85806c5590a5e3592a6215460887b6ca65cad4..0bbed30d9d19c738bade143915cbc3d10a91b8bc 100644
   3--- a/AGENTS.md
   4+++ b/AGENTS.md
   5@@ -43,6 +43,18 @@ almost fully allocated to the chat model.
   6 - Persona data contains no game, player, NPC, quest, or scenario context.
   7 - Keep the Bubble Tea event loop non-blocking.
   8 
   9+## Subagents
  10+
  11+- Run at most one worker subagent at a time. Never spawn two in parallel: the
  12+  harness model does not support concurrent turns, so parallel agents overwrite
  13+  each other's context cache and produce corrupt results.
  14+- Sequence work in dependency-ordered batches; wait for each agent to finish
  15+  before starting the next.
  16+- Give workers a plain-language description of the change (goal, behavior,
  17+  constraints, how to verify), not a full file to transcribe. The main context
  18+  does the design and thinking; the worker turns that into code. Pasting whole
  19+  files wastes context and defeats the purpose of offloading work.
  20+
  21 ## Development
  22 
  23 - Verify changes with `go build ./...` and `go vet ./...`. Do not write any
  24diff --git a/assets/maps/.gitkeep b/assets/maps/.gitkeep
  25deleted file mode 100644
  26index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
  27--- a/assets/maps/.gitkeep
  28+++ /dev/null
  29diff --git a/assets/maps/city.json b/assets/maps/city.json
  30deleted file mode 100644
  31index c2d94bd1d366de1eb81032ca711b1683ea9ece31..0000000000000000000000000000000000000000
  32--- a/assets/maps/city.json
  33+++ /dev/null
  34@@ -1,16 +0,0 @@
  35-{
  36-  "name": "city",
  37-  "rows": [
  38-    "########",
  39-    "#A.....#",
  40-    "#..S...#",
  41-    "#..T...#",
  42-    "########"
  43-  ],
  44-  "start": { "row": 1, "col": 2 },
  45-  "locations": [
  46-    { "id": "ramen-shop", "marker": "A", "label": "Ramen shop" },
  47-    { "id": "station", "marker": "S", "label": "Station" },
  48-    { "id": "tourist-info", "marker": "T", "label": "Tourist information" }
  49-  ]
  50-}
  51diff --git a/assets/personas/.gitkeep b/assets/personas/.gitkeep
  52deleted file mode 100644
  53index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
  54--- a/assets/personas/.gitkeep
  55+++ /dev/null
  56diff --git a/assets/personas/ramen-shop.json b/assets/personas/ramen-shop.json
  57deleted file mode 100644
  58index db10e2ebc2d0840b0b3fc0d07997f86bf4e992af..0000000000000000000000000000000000000000
  59--- a/assets/personas/ramen-shop.json
  60+++ /dev/null
  61@@ -1,5 +0,0 @@
  62-{
  63-  "name": "Kenji",
  64-  "location": "ramen-shop",
  65diff --git a/assets/personas/station.json b/assets/personas/station.json
  66deleted file mode 100644
  67index 8d3bbbedebcc7228bbf8a40a2c346dcc76376c05..0000000000000000000000000000000000000000
  68--- a/assets/personas/station.json
  69+++ /dev/null
  70@@ -1,5 +0,0 @@
  71-{
  72-  "name": "Sato",
  73-  "location": "station",
  74diff --git a/assets/personas/tourist-info.json b/assets/personas/tourist-info.json
  75deleted file mode 100644
  76index 6d96cbbe9596068e060107efe9d5d75154ccb3dc..0000000000000000000000000000000000000000
  77--- a/assets/personas/tourist-info.json
  78+++ /dev/null
  79@@ -1,5 +0,0 @@
  80-{
  81-  "name": "Miyu",
  82-  "location": "tourist-info",
  83diff --git a/assets/scenarios/city.json b/assets/scenarios/city.json
  84new file mode 100644
  85index 0000000000000000000000000000000000000000..eb8bf01aa7d49a2be21ca250e98cac66da775c94
  86--- /dev/null
  87+++ b/assets/scenarios/city.json
  88@@ -0,0 +1,4 @@
  89+{
  90+  "name": "city",
  91diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  92index de0bec38b5a39a3892754b44df40cf67c8664ea0..d36d64b98b2ef178bd5a0d455017a5785c9d7811 100644
  93--- a/cmd/jp/main.go
  94+++ b/cmd/jp/main.go
  95@@ -14,7 +14,7 @@ import (
  96 	"japanese/internal/config"
  97 	"japanese/internal/game"
  98 	"japanese/internal/llm"
  99-	"japanese/internal/persona"
 100+	"japanese/internal/scenario"
 101 	"japanese/internal/stt"
 102 	"japanese/internal/tts"
 103 	"japanese/internal/ui"
 104@@ -29,9 +29,14 @@ const warmupTimeout = 60 * time.Second
 105 func main() {
 106 	cfg := config.ParseConfig()
 107 
 108-	hc := &http.Client{Timeout: requestTimeout}
 109+	sc, err := scenario.Load(cfg.Scenario)
 110+	if err != nil {
 111+		fatalf("load scenario: %v", err)
 112+	}
 113 
 114+	hc := &http.Client{Timeout: requestTimeout}
 115 	llmClient := llm.NewClient(cfg, hc)
 116+
 117 	llmUp := true
 118 	if err := llmClient.LoadModel(); err != nil {
 119 		fmt.Fprintf(os.Stderr, "jp: LLM unavailable: %v\n", err)
 120@@ -44,53 +49,27 @@ func main() {
 121 		fmt.Fprintf(os.Stderr, "jp: STT unavailable: %v\n", err)
 122 	}
 123 
 124-	data, err := os.ReadFile(cfg.MapPath)
 125-	if err != nil {
 126-		fatalf("read map: %v", err)
 127-	}
 128-	mapData, err := game.ParseMap(data)
 129-	if err != nil {
 130-		fatalf("load map: %v", err)
 131-	}
 132-	personas, err := persona.LoadDir(cfg.Personas)
 133-	if err != nil {
 134-		fatalf("load personas: %v", err)
 135-	}
 136-	views := make(map[string]string, len(personas))
 137-	for _, p := range personas {
 138-		views[p.Location] = p.Description
 139-	}
 140 	if llmUp {
 141 		ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
 142-		err := llmClient.Warmup(ctx, warmupSystems(views))
 143+		err := llmClient.Warmup(ctx, warmupSystems(sc.Seed))
 144 		cancel()
 145 		if err != nil {
 146 			fmt.Fprintf(os.Stderr, "jp: LLM warmup failed: %v\n", err)
 147 		}
 148 	}
 149 
 150-	state := game.NewState(mapData)
 151-	playDone := make(chan struct{}, 1)
 152-	orch := buildOrchestrator(cfg, state, views, playDone, hc, llmClient)
 153+	state := game.NewState(sc.Seed)
 154+	orch := buildOrchestrator(cfg, state, hc, llmClient)
 155 
 156-	m := ui.NewModel(state, orch, serviceURLs(cfg), playDone, int(recordCap.Seconds()))
 157+	m := ui.NewModel(state, orch, int(recordCap.Seconds()))
 158 	if _, err := tea.NewProgram(m).Run(); err != nil {
 159 		fatalf("%v", err)
 160 	}
 161 }
 162 
 163-// serviceURLs maps each service name to its configured URL so the UI can name
 164-// the failing endpoint in error messages.
 165-func serviceURLs(cfg *config.Config) map[string]string {
 166-	return map[string]string{
 167-		"LLM": cfg.LLMConfig.BaseURL,
 168-		"TTS": cfg.TTSBaseURL,
 169-		"STT": cfg.STTURL,
 170-	}
 171-}
 172-
 173-func buildOrchestrator(cfg *config.Config, state *game.State, views map[string]string, playDone chan struct{}, hc *http.Client, llmClient *llm.Client) *game.Orchestrator {
 174-	npcModel := &adapters.NPCModel{Client: llmClient, Policy: llm.DefaultHistoryPolicy()}
 175+func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, llmClient *llm.Client) *game.Orchestrator {
 176+	gameModel := &adapters.GameModel{Client: llmClient}
 177+	compactor := &adapters.Compactor{Client: llmClient}
 178 	judgeModel := &adapters.JudgeModel{Client: llmClient}
 179 
 180 	speechIn := &adapters.SpeechInput{
 181@@ -100,26 +79,20 @@ func buildOrchestrator(cfg *config.Config, state *game.State, views map[string]s
 182 	speechOut := &adapters.SpeechOutput{
 183 		Client: tts.NewClient(cfg.TTSBaseURL, hc),
 184 		Player: tts.NewPlayer(),
 185-		OnDone: func() {
 186-			select {
 187-			case playDone <- struct{}{}:
 188-			default:
 189-			}
 190-		},
 191 	}
 192 
 193-	return game.NewOrchestrator(state, views, speechIn, npcModel, judgeModel, speechOut)
 194+	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, speechIn, speechOut)
 195diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
 196index 8123b23cf458efd2ae06e42704c81c597f52657b..b03da5ac6d9078bf8c7d82dc8bea915da2f24dec 100644
 197--- a/internal/adapters/llm.go
 198+++ b/internal/adapters/llm.go
 199@@ -9,14 +9,14 @@ import (
 200 	"context"
 201 	"fmt"
 202 	"os"
 203+	"strings"
 204 	"time"
 205 
 206-	"japanese/internal/game"
 207 	"japanese/internal/llm"
 208 )
 209 
 210-// rawLogPath is where raw model outputs are dumped for debugging, since the
 211-// TUI owns the terminal and direct stderr writes get mangled by redraws.
 212+// rawLogPath is where raw model outputs are dumped for debugging, since the TUI
 213+// owns the terminal and direct stderr writes get mangled by redraws.
 214 const rawLogPath = "jp_raw.log"
 215 
 216 func dumpRaw(label, transcript, raw string) {
 217@@ -28,25 +28,34 @@ func dumpRaw(label, transcript, raw string) {
 218 	fmt.Fprintf(f, "--- %s %s ---\ntranscript: %s\n%s\n\n", label, time.Now().Format(time.RFC3339), transcript, raw)
 219 }
 220 
 221-// NPCModel adapts an llm.Client to game.NPCModel. It builds the persona prompt
 222-// with bounded history and parses the strict three-field reply.
 223-type NPCModel struct {
 224+// GameModel adapts an llm.Client to game.GameModel. It sends the reconstructed
 225+// prompt and parses the unified six-field reply.
 226+type GameModel struct {
 227 	Client *llm.Client
 228-	Policy llm.HistoryPolicy
 229 }
 230 
 231-func (m *NPCModel) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply, error) {
 232-	policy := m.Policy
 233-	if policy.MaxTurns == 0 && policy.PromptCharBudget == 0 {
 234-		policy = llm.DefaultHistoryPolicy()
 235-	}
 236-	msgs := llm.BuildNPCMessages(req.Persona, req.Transcript, req.History, policy)
 237+func (m *GameModel) Advance(ctx context.Context, msgs []llm.Message) (llm.GameReply, error) {
 238 	raw, err := m.Client.Generate(ctx, msgs)
 239 	if err != nil {
 240-		return llm.NPCReply{}, err
 241+		return llm.GameReply{}, err
 242+	}
 243+	dumpRaw("game", "", raw)
 244+	return llm.ParseGameReply(raw)
 245+}
 246+
 247+// Compactor adapts an llm.Client to game.Compactor. It compresses one stretch of
 248+// messages at a location into a short memory using the compaction prompt.
 249+type Compactor struct {
 250+	Client *llm.Client
 251+}
 252+
 253+func (m *Compactor) Summarize(ctx context.Context, location string, msgs []llm.Message) (string, error) {
 254+	raw, err := m.Client.Generate(ctx, llm.BuildCompactionMessages(location, msgs))
 255+	if err != nil {
 256+		return "", err
 257 	}
 258-	dumpRaw("npc", req.Transcript, raw)
 259-	return llm.ParseNPCReply(raw)
 260+	dumpRaw("compact:"+location, "", raw)
 261+	return strings.TrimSpace(raw), nil
 262 }
 263 
 264 // JudgeModel adapts an llm.Client to game.JudgeModel. It issues the separate,
 265diff --git a/internal/config/config.go b/internal/config/config.go
 266index b38a1eb021debe2b3bc67e648349332af64f3e68..58876d5762901141cf1e186846e1f080e2eec84c 100644
 267--- a/internal/config/config.go
 268+++ b/internal/config/config.go
 269@@ -28,8 +28,7 @@ type Config struct {
 270 	STTLanguage   string `long:"stt-language" env:"JP_STT_LANGUAGE" default:"ja" description:"Whisper request language (ISO 639-1 code)"`
 271 	RecordCommand string `long:"record-command" default:"arecord" description:"Recorder command for 16kHz mono S16_LE WAV capture"`
 272 
 273-	MapPath  string `long:"map" env:"JP_MAP_PATH" default:"assets/maps/city.json" description:"path to the city map JSON"`
 274-	Personas string `long:"persona-dir" env:"JP_PERSONA_DIR" default:"assets/personas" description:"directory of persona JSON files"`
 275+	Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/city.json" description:"path to the scenario seed JSON"`
 276 }
 277 
 278 func ParseConfig() *Config {
 279diff --git a/internal/game/map.go b/internal/game/map.go
 280deleted file mode 100644
 281index 5e6a744f0ea57135d12a1303769dd28cb6f32c25..0000000000000000000000000000000000000000
 282--- a/internal/game/map.go
 283+++ /dev/null
 284@@ -1,157 +0,0 @@
 285-// Package game holds the pure free-roam game state and turn orchestration.
 286-// The map data types here are parsed from JSON asset files; parsing is pure
 287-// and does no I/O, so the rest of the package can stay free of network, audio,
 288-// and UI dependencies.
 289-package game
 290-
 291-import (
 292-	"bytes"
 293-	"encoding/json"
 294-	"fmt"
 295-)
 296-
 297-const (
 298-	blockedCell = '#'
 299-	floorCell   = '.'
 300-)
 301-
 302-// Start is the player's starting cell on the grid.
 303-type Start struct {
 304-	Row int `json:"row"`
 305-	Col int `json:"col"`
 306-}
 307-
 308-// LocationDef names one free-roam location: a stable id, the single grid rune
 309-// that marks it, and a romaji-only label for the UI.
 310-type LocationDef struct {
 311-	ID     string `json:"id"`
 312-	Marker string `json:"marker"`
 313-	Label  string `json:"label"`
 314-}
 315-
 316-// MapData is the parsed city map: the walkable/blocked grid, the player start
 317-// cell, and the locations marked on it.
 318-type MapData struct {
 319-	Name      string        `json:"name"`
 320-	Rows      []string      `json:"rows"`
 321-	Start     Start         `json:"start"`
 322-	Locations []LocationDef `json:"locations"`
 323-}
 324-
 325-// ParseMap decodes and validates a city map from JSON. Unknown fields are
 326-// rejected so malformed data fails to load with a clear error.
 327-func ParseMap(data []byte) (MapData, error) {
 328-	var m MapData
 329-	dec := json.NewDecoder(bytes.NewReader(data))
 330-	dec.DisallowUnknownFields()
 331-	if err := dec.Decode(&m); err != nil {
 332-		return MapData{}, fmt.Errorf("map: decode: %w", err)
 333-	}
 334-	if err := ValidateMap(m); err != nil {
 335-		return MapData{}, err
 336-	}
 337-	return m, nil
 338-}
 339-
 340-// ValidateMap checks the map's structure: a rectangular grid, an in-bounds
 341-// walkable start cell, and well-formed unique locations each placed on the grid.
 342-func ValidateMap(m MapData) error {
 343-	if m.Name == "" {
 344-		return fmt.Errorf("map: name must not be empty")
 345-	}
 346-	if len(m.Rows) == 0 {
 347-		return fmt.Errorf("map: rows must not be empty")
 348-	}
 349-	width := -1
 350-	for i, row := range m.Rows {
 351-		n := len(row)
 352-		if width == -1 {
 353-			width = n
 354-		} else if n != width {
 355-			return fmt.Errorf("map: row %d has length %d, want %d", i, n, width)
 356-		}
 357-	}
 358-	height := len(m.Rows)
 359-
 360-	if m.Start.Row < 0 || m.Start.Row >= height || m.Start.Col < 0 || m.Start.Col >= width {
 361-		return fmt.Errorf("map: start (%d,%d) is out of bounds", m.Start.Row, m.Start.Col)
 362-	}
 363-	if cellAt(m.Rows, m.Start.Row, m.Start.Col) == blockedCell {
 364-		return fmt.Errorf("map: start (%d,%d) is on a blocked cell", m.Start.Row, m.Start.Col)
 365-	}
 366-
 367-	seenID := make(map[string]bool, len(m.Locations))
 368-	seenMarker := make(map[rune]string, len(m.Locations))
 369-	for i, loc := range m.Locations {
 370-		if loc.ID == "" {
 371-			return fmt.Errorf("map: location %d id must not be empty", i)
 372-		}
 373-		if seenID[loc.ID] {
 374-			return fmt.Errorf("map: duplicate location id %q", loc.ID)
 375-		}
 376-		seenID[loc.ID] = true
 377-
 378-		marker, ok := singleRune(loc.Marker)
 379-		if !ok {
 380-			return fmt.Errorf("map: location %s marker must be exactly one character, got %q", loc.ID, loc.Marker)
 381-		}
 382-		if marker == blockedCell || marker == floorCell {
 383-			return fmt.Errorf("map: location %s marker %q collides with a grid cell", loc.ID, loc.Marker)
 384diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
 385index 96d649e375cd773995e8fb72795a403c839d65b2..da7bb0972ec0340a61a00739c91c55c33d0a628d 100644
 386--- a/internal/game/orchestrator.go
 387+++ b/internal/game/orchestrator.go
 388@@ -3,7 +3,9 @@ package game
 389 import (
 390 	"context"
 391 	"errors"
 392+	"strings"
 393 	"sync"
 394+	"unicode/utf8"
 395 
 396 	"japanese/internal/llm"
 397 )
 398@@ -15,137 +17,227 @@ type SpeechInput interface {
 399 	End(ctx context.Context) (string, error)
 400 }
 401 
 402-// NPCRequest carries everything the NPC model needs for one reply. Judge output
 403-// never appears in it.
 404-type NPCRequest struct {
 405-	Persona    string     // stable persona description
 406-	Transcript string     // raw transcript (Japanese)
 407-	History    []llm.Turn // prior turns; the adapter bounds this
 408-}
 409-
 410-// NPCModel produces one NPC reply for a request.
 411-type NPCModel interface {
 412-	Reply(ctx context.Context, req NPCRequest) (llm.NPCReply, error)
 413+// SpeechOutput synthesizes and plays an NPC reply's audio from its kana.
 414+type SpeechOutput interface {
 415+	Speak(ctx context.Context, kana string) error
 416 }
 417 
 418-// JudgeModel scores one spoken line. It is independent of the NPC model.
 419+// JudgeModel scores one spoken line. It is independent of the game model.
 420 type JudgeModel interface {
 421 	Judge(ctx context.Context, transcript string) (llm.JudgeResult, error)
 422 }
 423 
 424-// SpeechOutput synthesizes and plays an NPC reply's audio from its kana.
 425-type SpeechOutput interface {
 426-	Speak(ctx context.Context, kana string) error
 427+// GameModel advances the game loop: it sends the reconstructed prompt and returns
 428+// the parsed unified reply. Judge output never enters this path.
 429+type GameModel interface {
 430+	Advance(ctx context.Context, msgs []llm.Message) (llm.GameReply, error)
 431 }
 432 
 433-// ErrNotInLocation is returned when a turn is finished outside any location.
 434-var ErrNotInLocation = errors.New("game: not in a location")
 435+// Compactor summarizes one stretch of messages at a location into a short memory.
 436+type Compactor interface {
 437+	Summarize(ctx context.Context, location string, msgs []llm.Message) (string, error)
 438+}
 439 
 440-// TurnResult reports what happened in one turn. The display romaji comes from
 441-// Judge when it succeeds, and there is no romaji when it fails. A non-nil Err
 442-// means no NPC reply was produced, so nothing was recorded to history. JudgeErr
 443-// and SpeakErr are recoverable: they do not prevent recording and are surfaced
 444-// separately.
 445+// ErrEmptyTranscript is returned when push-to-talk produced no usable transcript.
 446+var ErrEmptyTranscript = errors.New("game: empty transcript")
 447+
 448+// TurnResult reports what happened in one turn. Reply is always set unless Err is
 449+// non-nil (no reply was produced, so the session did not advance). Judge and
 450+// JudgeErr apply to spoken turns only. SpeakErr is a recoverable TTS failure.
 451 type TurnResult struct {
 452+	Reply    llm.GameReply
 453 	Judge    llm.JudgeResult
 454-	JudgeErr error
 455+	HasJudge bool
 456 	SpeakErr error
 457+	JudgeErr error
 458 	Err      error
 459 }
 460 
 461 // Orchestrator runs the turn flow over the adapter interfaces, keeping all game
 462-// state in the embedded State. It does no I/O itself; every adapter call is an
 463-// explicit await on the caller's context, so it is safe to run off a UI event
 464-// loop (for example from a Bubble Tea command goroutine).
 465+// state in the embedded State. It does no I/O itself; every adapter call awaits on
 466+// the caller's context, so it is safe to run off a UI event loop (for example from
 467+// a Bubble Tea command goroutine).
 468 type Orchestrator struct {
 469-	state    *State
 470-	personas map[string]string // location -> stable persona description
 471-	speechIn  SpeechInput
 472-	npc       NPCModel
 473+	state     *State
 474+	game      GameModel
 475 	judge     JudgeModel
 476+	compactor Compactor
 477+	speechIn  SpeechInput
 478 	speechOut SpeechOutput
 479+
 480+	staticChars int
 481+	budget      int
 482 }
 483 
 484-// NewOrchestrator wires an orchestrator over a state and the four adapters.
 485-func NewOrchestrator(state *State, personas map[string]string, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) *Orchestrator {
 486+// NewOrchestrator wires an orchestrator over a state and the five adapters.
 487+func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, in SpeechInput, out SpeechOutput) *Orchestrator {
 488diff --git a/internal/game/state.go b/internal/game/state.go
 489index 6c46fc7028f90439e02c49f7943a826a94dec731..a8cf93ce2077b4417488cf77c1ab9a02607517c9 100644
 490--- a/internal/game/state.go
 491+++ b/internal/game/state.go
 492@@ -1,198 +1,255 @@
 493 package game
 494 
 495-// Direction is one grid step the player can attempt.
 496-type Direction int
 497+import (
 498+	"fmt"
 499+	"strings"
 500+	"unicode/utf8"
 501 
 502-const (
 503-	Up Direction = iota
 504-	Down
 505-	Left
 506-	Right
 507+	"japanese/internal/llm"
 508 )
 509 
 510-func (d Direction) step() (dr, dc int) {
 511-	switch d {
 512-	case Up:
 513-		return -1, 0
 514-	case Down:
 515-		return 1, 0
 516-	case Left:
 517-		return 0, -1
 518-	case Right:
 519-		return 0, 1
 520-	}
 521-	return 0, 0
 522-}
 523-
 524-// Position is a cell coordinate on the map grid.
 525-type Position struct {
 526-	Row int
 527-	Col int
 528+// Message is a role/content pair owned by the game layer so session and display
 529+// code can carry turns without importing llm into render paths. Role is a plain
 530+// string: "user" or "assistant".
 531+type Message struct {
 532+	Role    string
 533+	Content string
 534 }
 535 
 536-// Reveal tracks which of an NPC reply's text fields have been shown to the
 537-// player. Romaji is always revealed before English.
 538-type Reveal int
 539-
 540 const (
 541-	// Hidden: neither romaji nor English has been revealed yet.
 542-	Hidden Reveal = iota
 543-	// RomajiShown: romaji is visible; English is still hidden.
 544-	RomajiShown
 545-	// EnglishShown: both romaji and English are visible.
 546-	EnglishShown
 547+	roleUser      = "user"
 548+	roleAssistant = "assistant"
 549 )
 550 
 551-// Reply is one NPC utterance kept in a location's history. Both text fields
 552-// start hidden; Reveal advances romaji first, then English.
 553-type Reply struct {
 554-	Romaji  string
 555-	English string
 556-	Reveal  Reveal
 557+// Segment is one continuous stretch of the session at a single location. While
 558+// open it holds raw Messages; once closed, Messages are dropped and Summary
 559+// stands in for them in every future prompt. Prefix summarizes any oldest
 560+// messages compacted out of an over-long open segment before it closes.
 561+type Segment struct {
 562+	Location string
 563+	Prefix   string
 564+	Messages []Message
 565+	Summary  string
 566 }
 567 
 568-// Turn is one exchange in a location's history: the player's spoken line and
 569-// the NPC reply to it. PlayerRaw and NPCSpoken are the original Japanese
 570-// utterances, kept for LLM prompts only; NPC holds the display fields.
 571-type Turn struct {
 572-	PlayerRaw string
 573-	NPCSpoken string
 574-	NPC       Reply
 575+// DisplayEntry is one turn as shown in the adventure log. Speech fields are
 576+// empty and HasSpeech false for pure narration turns. ShowRomaji and
 577+// ShowEnglish are independent: either can be revealed in either order.
 578+type DisplayEntry struct {
 579+	Desc        string
 580+	Romaji      string
 581+	English     string
 582+	HasSpeech   bool
 583+	Score       int
 584+	Feedback    string
 585+	HasJudge    bool
 586+	ShowRomaji  bool
 587+	ShowEnglish bool
 588 }
 589 
 590-// State is the pure, in-memory free-roam game state: where the player stands,
 591-// which location (if any) they are in, and each location's conversation history.
 592diff --git a/internal/llm/contract.go b/internal/llm/contract.go
 593index 15c8dd648885abc5a1a7ef45dabe717bb0323dbb..0e5c1a4ee6f96c0bb80b3bd7c7c8930b9c442516 100644
 594--- a/internal/llm/contract.go
 595+++ b/internal/llm/contract.go
 596@@ -13,6 +13,7 @@ const (
 597 	EmptyValue
 598 	InvalidFormat
 599 	OutOfRange
 600+	PartialTriple
 601 )
 602 
 603 // ContractError is a recoverable, inspectable failure to parse a model output
 604@@ -38,6 +39,8 @@ func kindString(k ContractErrorKind) string {
 605 		return "invalid format"
 606 	case OutOfRange:
 607 		return "out of range"
 608+	case PartialTriple:
 609+		return "partial spoken triple"
 610 	default:
 611 		return "unknown"
 612 	}
 613@@ -46,6 +49,7 @@ func kindString(k ContractErrorKind) string {
 614 var (
 615 	npcFieldNames   = []string{"ROMAJI", "KANA", "ENGLISH"}
 616 	judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
 617+	gameFieldNames  = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
 618 )
 619 
 620 // parseFields splits a raw model reply into FIELD|value lines. Surrounding
 621@@ -137,3 +141,50 @@ func ParseJudge(raw string) (JudgeResult, error) {
 622 	}
 623 	return JudgeResult{Score: score, Romaji: vals["ROMAJI"], Feedback: vals["FEEDBACK"]}, nil
 624 }
 625+
 626+type GameReply struct {
 627+	Location string // stable id of the player's current place, always present
 628+	Talk     string // name of the person being spoken to, or "none"; always present
 629+	Desc     string // English narration; may be empty
 630+	Romaji   string // NPC speech in romaji; part of the spoken triple
 631+	Kana     string // same sentence in kana; part of the spoken triple
 632+	English  string // natural English translation; part of the spoken triple
 633+}
 634+
 635+// ParseGameReply parses the unified game reply contract. LOCATION and TALK are
 636+// required and must be non-empty. The spoken triple (ROMAJI, KANA, ENGLISH) is
 637+// all-or-nothing by presence: either none of the three keys appear or all three
 638+// do, though their values may be empty strings. DESC is optional and defaults to
 639+// "". A malformed reply is a recoverable *ContractError.
 640+func ParseGameReply(raw string) (GameReply, error) {
 641+	vals, err := parseFields(raw, gameFieldNames)
 642+	if err != nil {
 643+		return GameReply{}, err
 644+	}
 645+	for _, name := range []string{"LOCATION", "TALK"} {
 646+		v, ok := vals[name]
 647+		if !ok {
 648+			return GameReply{}, &ContractError{Kind: MissingField, Field: name}
 649+		}
 650+		if v == "" {
 651+			return GameReply{}, &ContractError{Kind: EmptyValue, Field: name}
 652+		}
 653+	}
 654+	present := 0
 655+	for _, name := range []string{"ROMAJI", "KANA", "ENGLISH"} {
 656+		if _, ok := vals[name]; ok {
 657+			present++
 658+		}
 659+	}
 660+	if present == 1 || present == 2 {
 661+		return GameReply{}, &ContractError{Kind: PartialTriple, Field: "spoken"}
 662+	}
 663+	return GameReply{
 664+		Location: vals["LOCATION"],
 665+		Talk:     vals["TALK"],
 666+		Desc:     vals["DESC"],
 667+		Romaji:   vals["ROMAJI"],
 668+		Kana:     vals["KANA"],
 669+		English:  vals["ENGLISH"],
 670+	}, nil
 671+}
 672diff --git a/internal/llm/history.go b/internal/llm/history.go
 673index fd4ed88a07179f1e4deb87bdbf05f8d49001b3e3..40829d0490e833133785ec2c12ac526a0d6df888 100644
 674--- a/internal/llm/history.go
 675+++ b/internal/llm/history.go
 676@@ -9,7 +9,7 @@ type Turn struct {
 677 }
 678 
 679 const (
 680-	maxContextTokens      = 8192
 681+	maxContextTokens      = 16384
 682 	charsPerTokenEstimate = 1
 683 	defaultMaxTurns       = 8
 684 )
 685diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
 686index 2f8aba322ee4fd3ede456a6502609d291db78b97..9233815ff846a83a09612a6bc8e0c16454ee4457 100644
 687--- a/internal/llm/prompt.go
 688+++ b/internal/llm/prompt.go
 689@@ -89,3 +89,56 @@ func BuildJudgeMessages(transcript string) []Message {
 690 		{Role: RoleUser, Content: "Spoken line: " + strings.TrimSpace(transcript)},
 691 	}
 692 }
 693+
 694+// gameSystemTemplate is the game-loop system message. It wraps the authored
 695+// world seed and fixes the six-field reply contract for advancing the scene.
 696diff --git a/internal/persona/.gitkeep b/internal/persona/.gitkeep
 697deleted file mode 100644
 698index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
 699--- a/internal/persona/.gitkeep
 700+++ /dev/null
 701diff --git a/internal/persona/persona.go b/internal/persona/persona.go
 702deleted file mode 100644
 703index f295e339eeebddb1de29f10e1ea026271ff8784e..0000000000000000000000000000000000000000
 704--- a/internal/persona/persona.go
 705+++ /dev/null
 706@@ -1,68 +0,0 @@
 707-// Package persona holds the authored persona data that drives NPC speech. A
 708-// persona is stable data bound to a single location; its description carries
 709-// all instructions for playing the person.
 710-package persona
 711-
 712-import (
 713-	"encoding/json"
 714-	"fmt"
 715-	"os"
 716-	"path/filepath"
 717-	"sort"
 718-)
 719-
 720-// Persona is one authored NPC character bound to a single location. The
 721-// description contains everything needed to play the person: who they are,
 722-// how they speak, the local situation, and how they handle unclear input.
 723-type Persona struct {
 724-	Name        string `json:"name"`
 725-	Location    string `json:"location"`
 726-	Description string `json:"description"`
 727-}
 728-
 729-// Parse decodes one persona from JSON.
 730-func Parse(data []byte) (Persona, error) {
 731-	var p Persona
 732-	if err := json.Unmarshal(data, &p); err != nil {
 733-		return Persona{}, fmt.Errorf("persona: decode: %w", err)
 734-	}
 735-	return p, nil
 736-}
 737-
 738-// LoadDir reads every *.json file in dir (sorted by filename) and returns the
 739-// parsed personas. It also rejects two personas bound to the same location.
 740-func LoadDir(dir string) ([]Persona, error) {
 741-	entries, err := os.ReadDir(dir)
 742-	if err != nil {
 743-		return nil, fmt.Errorf("persona: read dir %s: %w", dir, err)
 744-	}
 745-	var files []string
 746-	for _, e := range entries {
 747-		if e.IsDir() {
 748-			continue
 749-		}
 750-		if filepath.Ext(e.Name()) == ".json" {
 751-			files = append(files, e.Name())
 752-		}
 753-	}
 754-	sort.Strings(files)
 755-
 756-	out := make([]Persona, 0, len(files))
 757-	seen := make(map[string]string, len(files))
 758-	for _, name := range files {
 759-		data, err := os.ReadFile(filepath.Join(dir, name))
 760-		if err != nil {
 761-			return nil, fmt.Errorf("persona: read %s: %w", name, err)
 762-		}
 763-		p, err := Parse(data)
 764-		if err != nil {
 765-			return nil, fmt.Errorf("persona: %s: %w", name, err)
 766-		}
 767-		if prev, dup := seen[p.Location]; dup {
 768-			return nil, fmt.Errorf("persona: location %q bound by both %s and %s", p.Location, prev, name)
 769-		}
 770-		seen[p.Location] = name
 771-		out = append(out, p)
 772-	}
 773-	return out, nil
 774-}
 775diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go
 776new file mode 100644
 777index 0000000000000000000000000000000000000000..7bd52c1df66b78e34b79dac9e1ffde3332915740
 778--- /dev/null
 779+++ b/internal/scenario/scenario.go
 780@@ -0,0 +1,42 @@
 781+// Package scenario holds the authored world seed that opens the game-loop LLM
 782+// context. A scenario names the town, the starting situation, and a few key
 783+// people and places; the model may expand on these facts but must stay
 784+// consistent with them.
 785+package scenario
 786+
 787+import (
 788+	"bytes"
 789+	"encoding/json"
 790+	"fmt"
 791+	"os"
 792+	"strings"
 793+)
 794+
 795+// Scenario is one authored world: a display name plus the seed text that opens
 796+// the game-loop system prompt.
 797+type Scenario struct {
 798+	Name string `json:"name"`
 799+	Seed string `json:"seed"`
 800+}
 801+
 802+// Load reads and validates a scenario from path. Unknown fields are rejected so
 803+// malformed data fails to load with a clear error.
 804+func Load(path string) (Scenario, error) {
 805+	data, err := os.ReadFile(path)
 806+	if err != nil {
 807+		return Scenario{}, fmt.Errorf("scenario: read %s: %w", path, err)
 808+	}
 809+	var s Scenario
 810+	dec := json.NewDecoder(bytes.NewReader(data))
 811+	dec.DisallowUnknownFields()
 812+	if err := dec.Decode(&s); err != nil {
 813+		return Scenario{}, fmt.Errorf("scenario: decode: %w", err)
 814+	}
 815+	if s.Name == "" {
 816+		return Scenario{}, fmt.Errorf("scenario: name must not be empty")
 817+	}
 818+	if strings.TrimSpace(s.Seed) == "" {
 819+		return Scenario{}, fmt.Errorf("scenario: seed must not be empty")
 820+	}
 821+	return s, nil
 822+}
 823diff --git a/internal/ui/app.go b/internal/ui/app.go
 824index aaca899e94f0fe370ca17dc6f4571c95610b898a..a978e0613643a5783b50308cf71760ba951b6806 100644
 825--- a/internal/ui/app.go
 826+++ b/internal/ui/app.go
 827@@ -2,9 +2,7 @@ package ui
 828 
 829 import (
 830 	"context"
 831-	"errors"
 832 	"fmt"
 833-	"strings"
 834 	"time"
 835 
 836 	"github.com/charmbracelet/bubbletea"
 837@@ -12,135 +10,110 @@ import (
 838 	"japanese/internal/game"
 839 )
 840 
 841-// tickInterval refreshes the recording elapsed indicator. It is short enough to
 842-// feel live but cheap; it only runs while a recording is in progress.
 843+// tickInterval refreshes the recording elapsed indicator while a capture is open.
 844 const tickInterval = 200 * time.Millisecond
 845 
 846-type talkPhase int
 847+type phase int
 848 
 849 const (
 850-	phaseIdle talkPhase = iota
 851+	phaseIdle phase = iota
 852 	phaseRecording
 853 	phaseProcessing
 854 )
 855 
 856 type beginResultMsg struct{ err error }
 857 type finishResultMsg struct{ res game.TurnResult }
 858-type tickMsg struct{ t time.Time }
 859-type playDoneMsg struct{}
 860+type tickMsg struct{}
 861 
 862+// model is the Bubble Tea state. It owns only UI-local fields (input buffer,
 863+// phase, status); all session truth lives in the embedded State, which is
 864+// mutated either by reveal calls here or by the orchestrator workers below.
 865 type model struct {
 866 	state        *game.State
 867 	orch         *game.Orchestrator
 868-	svcURLs      map[string]string
 869-	playDone     chan struct{}
 870 	recordCapSec int
 871 
 872-	phase     talkPhase
 873+	phase     phase
 874 	beginning bool
 875 	recSince  time.Time
 876 
 877-	transcriptRomaji string
 878-	hasJudge         bool
 879-	judgeScore       int
 880-	judgeFeedback    string
 881-	judgeErr         string
 882-	npcStatus        string
 883-	status           string
 884+	input  string
 885+	status string
 886+	height int
 887 }
 888 
 889-func NewModel(state *game.State, orch *game.Orchestrator, svcURLs map[string]string, playDone chan struct{}, recordCapSec int) *model {
 890+func NewModel(state *game.State, orch *game.Orchestrator, recordCapSec int) *model {
 891 	return &model{
 892 		state:        state,
 893 		orch:         orch,
 894-		svcURLs:      svcURLs,
 895-		playDone:     playDone,
 896 		recordCapSec: recordCapSec,
 897+		height:       24,
 898 	}
 899 }
 900 
 901-func (m *model) Init() tea.Cmd { return m.listenPlayDone() }
 902+func (m *model) Init() tea.Cmd { return nil }
 903 
 904 func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 905-	var cmds []tea.Cmd
 906+	var cmd tea.Cmd
 907 	switch v := msg.(type) {
 908 	case tea.KeyMsg:
 909-		if c := m.handleKey(v); c != nil {
 910-			cmds = append(cmds, c)
 911-		}
 912+		cmd = m.handleKey(v)
 913 	case beginResultMsg:
 914 		m.applyBegin(v.err)
 915 	case finishResultMsg:
 916 		m.applyFinish(v.res)
 917-	case playDoneMsg:
 918-		if m.npcStatus == "speaking" {
 919-			m.npcStatus = "done"
 920-		}
 921-		if cmd := m.listenPlayDone(); cmd != nil {
 922-			cmds = append(cmds, cmd)
 923-		}
 924 	case tickMsg:
 925 		if m.phase == phaseRecording {
 926-			cmds = append(cmds, nextTick())
 927diff --git a/internal/ui/render.go b/internal/ui/render.go
 928index ce33cd866dc80f3ca0fb71ff51d41a386cecc2e9..a16e1f20cfef614587c6c0513f4ab2d42a9df01d 100644
 929--- a/internal/ui/render.go
 930+++ b/internal/ui/render.go
 931@@ -8,134 +8,53 @@ import (
 932 	"strings"
 933 )
 934 
 935-type locLine struct {
 936-	id     string
 937-	marker string
 938-	label  string
 939-}
 940-
 941-// controlsHint is the idle status line. It only lists keys wired in this build.
 942-const controlsHint = "move: arrows / WASD   Space: talk   R: romaji   T: english   q: quit"
 943-
 944-// transcriptUnavailable is shown for the player's line when the judge request
 945-// failed, so no raw Japanese can ever reach the view.
 946-const transcriptUnavailable = "(transcript unavailable)"
 947+// controlsHint is the idle status line. It lists only keys wired in this build.
 948+const controlsHint = "F2 talk · F3 romaji · F4 english · Esc quit"
 949 
 950 // viewState is the plain, render-only snapshot the pure render functions take.
 951-// It holds no pointers and no game types, so rendering is trivially pure.
 952+// It holds no pointers and no game types, so rendering stays trivially pure.
 953 type viewState struct {
 954-	mapRows  []string
 955-	posRow   int
 956-	posCol   int
 957-	activeID string
 958-	locs     []locLine
 959-
 960-	phaseWord     string
 961-	recElapsedSec int
 962-	recCapSec     int
 963-
 964-	transcriptRomaji string
 965-	npcStatus        string
 966-	romajiShown      bool
 967-	englishShown     bool
 968-	npcRomaji        string
 969-	npcEnglish       string
 970-
 971-	hasJudge      bool
 972-	judgeScore    int
 973-	judgeFeedback string
 974-	judgeErr      string
 975-
 976-	status string
 977+	logLines   []string
 978+	input      string
 979+	status     string
 980+	phaseWord  string
 981+	recElapsed int
 982+	recCap     int
 983+	height     int
 984 }
 985 
 986 func render(vs viewState) string {
 987-	var b strings.Builder
 988-	b.WriteString(mapBlock(vs.mapRows, vs.posRow, vs.posCol))
 989-	if line := legendBlock(vs.locs, vs.activeID); line != "" {
 990-		b.WriteString(line)
 991-		b.WriteByte('\n')
 992-	}
 993-	b.WriteString(dialogueBlock(vs.transcriptRomaji, vs.npcStatus, vs.romajiShown, vs.englishShown, vs.npcRomaji, vs.npcEnglish))
 994-	b.WriteString(learningBlock(vs.hasJudge, vs.judgeScore, vs.judgeFeedback, vs.judgeErr))
 995-	b.WriteByte('\n')
 996-	b.WriteString(statusLine(vs.status, vs.phaseWord, vs.recElapsedSec, vs.recCapSec))
 997-	return b.String()
 998-}
 999-
1000-func mapBlock(rows []string, posRow, posCol int) string {
1001-	var b strings.Builder
1002-	for r, row := range rows {
1003-		line := make([]rune, 0, len(row))
1004-		for c, ch := range row {
1005-			if r == posRow && c == posCol {
1006-				line = append(line, '@')
1007-			} else {
1008-				line = append(line, ch)
1009-			}
1010-		}
1011-		b.WriteString(sanitizeRomajiOnly(string(line)))
1012-		b.WriteByte('\n')
1013-	}
1014-	return b.String()
1015-}
1016-
1017-func legendBlock(locs []locLine, activeID string) string {
1018-	if len(locs) == 0 {
1019-		return ""
1020-	}
1021-	var b strings.Builder
1022-	b.WriteString("Locations: ")
1023-	for i, l := range locs {
1024-		mark := " "
1025-		if l.id == activeID {
1026-			mark = "*"
1027-		}
1028-		fmt.Fprintf(&b, "%s%s %s", mark, sanitizeRomajiOnly(l.marker), sanitizeRomajiOnly(l.label))
1029-		if i < len(locs)-1 {
1030-			b.WriteString("   ")