5f34f56839ca12659cc8547e7afe3a35df51732c
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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(" ")