8976c674b65f3d10bf403584a979d44e39bc7aa4

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

Message

game: add turn orchestration over adapter interfaces

Diff

This diff is truncated to protect this page.

  1diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..a2d85f4f3ce096aabcae00fd6fee3c583ef9aa2b
  4--- /dev/null
  5+++ b/internal/adapters/llm.go
  6@@ -0,0 +1,48 @@
  7+// Package adapters provides the concrete game adapters that wrap the existing
  8+// HTTP clients (llm, stt, tts). They implement the interfaces defined in
  9+// internal/game so the orchestrator can be wired to real services here and to
 10+// fakes in tests. This package is an HTTP client only; it never starts or
 11+// manages any model service.
 12+package adapters
 13+
 14+import (
 15+	"context"
 16+
 17+	"japanese/internal/game"
 18+	"japanese/internal/llm"
 19+)
 20+
 21+// NPCModel adapts an llm.Client to game.NPCModel. It builds the persona and
 22+// situation prompt with bounded history and parses the strict three-field reply.
 23+type NPCModel struct {
 24+	Client *llm.Client
 25+	Policy llm.HistoryPolicy
 26+}
 27+
 28+func (m *NPCModel) Reply(ctx context.Context, req game.NPCRequest) (llm.NPCReply, error) {
 29+	policy := m.Policy
 30+	if policy.MaxTurns == 0 && policy.PromptCharBudget == 0 {
 31+		policy = llm.DefaultHistoryPolicy()
 32+	}
 33+	msgs := llm.BuildNPCMessages(req.Persona, req.Situation, req.Transcript, req.History, policy)
 34+	raw, err := m.Client.Generate(ctx, msgs, llm.Qwen3Options())
 35+	if err != nil {
 36+		return llm.NPCReply{}, err
 37+	}
 38+	return llm.ParseNPCReply(raw)
 39+}
 40+
 41+// JudgeModel adapts an llm.Client to game.JudgeModel. It issues the separate,
 42+// persona-free judge request and parses the two-field result.
 43+type JudgeModel struct {
 44+	Client *llm.Client
 45+}
 46+
 47+func (m *JudgeModel) Judge(ctx context.Context, req game.JudgeRequest) (llm.JudgeResult, error) {
 48+	msgs := llm.BuildJudgeMessages(req.Situation, req.Transcript)
 49+	raw, err := m.Client.Generate(ctx, msgs, llm.Qwen3Options())
 50+	if err != nil {
 51+		return llm.JudgeResult{}, err
 52+	}
 53+	return llm.ParseJudge(raw)
 54+}
 55diff --git a/internal/adapters/llm_test.go b/internal/adapters/llm_test.go
 56new file mode 100644
 57index 0000000000000000000000000000000000000000..e6331d5ec4702336f09e3b4fa6875e3bd996f1b6
 58--- /dev/null
 59+++ b/internal/adapters/llm_test.go
 60@@ -0,0 +1,155 @@
 61+package adapters
 62+
 63+import (
 64+	"context"
 65+	"encoding/json"
 66+	"errors"
 67+	"fmt"
 68+	"net/http"
 69+	"net/http/httptest"
 70+	"strings"
 71+	"testing"
 72+
 73+	"japanese/internal/game"
 74+	"japanese/internal/llm"
 75+)
 76+
 77+type llmCapture struct{ body []byte }
 78+
 79+func newFakeLLM(t *testing.T, respond func(w http.ResponseWriter)) (*llm.Client, *llmCapture) {
 80+	t.Helper()
 81+	cap := &llmCapture{}
 82+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 83+		buf := make([]byte, 8192)
 84+		n, _ := r.Body.Read(buf)
 85+		cap.body = buf[:n]
 86+		respond(w)
 87+	}))
 88+	t.Cleanup(srv.Close)
 89+	return llm.NewClient(srv.URL, srv.Client()), cap
 90+}
 91+
 92+func sseReply(w http.ResponseWriter, full string) {
 93+	w.Header().Set("Content-Type", "text/event-stream")
 94+	b, _ := json.Marshal(map[string]any{
 95+		"choices": []map[string]any{{"delta": map[string]any{"content": full}}},
 96+	})
 97+	fmt.Fprintf(w, "data: %s\n\n", b)
 98+	fmt.Fprint(w, "data: [DONE]\n\n")
 99+}
100+
101+type capturedChat struct {
102+	Messages []struct {
103+		Role    string `json:"role"`
104+		Content string `json:"content"`
105+	} `json:"messages"`
106+}
107+
108+func TestNPCModelReplyAndRequestShape(t *testing.T) {
109+	client, cap := newFakeLLM(t, func(w http.ResponseWriter) {
110+		sseReply(w, "ROMAJI|romaji-reply\nKANA|kana-reply\nENGLISH|english-reply")
111+	})
112+	m := &NPCModel{Client: client}
113+
114+	got, err := m.Reply(context.Background(), game.NPCRequest{
115+		Persona:    "a careful clerk",
116+		Situation:  "at a ticket window",
117+		Transcript: "raw-line",
118+	})
119+	if err != nil {
120+		t.Fatalf("Reply: %v", err)
121+	}
122+	if got.Romaji != "romaji-reply" || got.Kana != "kana-reply" || got.English != "english-reply" {
123+		t.Errorf("unexpected reply: %+v", got)
124+	}
125+
126+	var req capturedChat
127+	if err := json.Unmarshal(cap.body, &req); err != nil {
128+		t.Fatalf("request not JSON: %v", err)
129+	}
130+	system := req.Messages[0].Content
131+	if !strings.Contains(system, "a careful clerk") || !strings.Contains(system, "at a ticket window") {
132+		t.Errorf("system message missing persona/situation: %q", system)
133+	}
134+	if last := req.Messages[len(req.Messages)-1]; last.Content != "raw-line" {
135+		t.Errorf("last message = %q, want raw transcript", last.Content)
136+	}
137+}
138+
139+func TestNPCModelMalformedSurfacesContractError(t *testing.T) {
140+	client, _ := newFakeLLM(t, func(w http.ResponseWriter) {
141+		sseReply(w, "ROMAJI|r\nENGLISH|e") // missing KANA
142+	})
143+	m := &NPCModel{Client: client}
144+
145+	if _, err := m.Reply(context.Background(), game.NPCRequest{Persona: "p", Situation: "s", Transcript: "t"}); err == nil {
146+		t.Fatal("expected error for malformed NPC output")
147+	} else {
148+		var ce *llm.ContractError
149+		if !errors.As(err, &ce) {
150+			t.Errorf("expected *llm.ContractError, got %T (%v)", err, err)
151+		}
152+	}
153+}
154+
155+func TestNPCModelBoundsHistory(t *testing.T) {
156+	client, cap := newFakeLLM(t, func(w http.ResponseWriter) {
157+		sseReply(w, "ROMAJI|r\nKANA|k\nENGLISH|e")
158+	})
159+	m := &NPCModel{Client: client, Policy: llm.HistoryPolicy{MaxTurns: 2, PromptCharBudget: 1_000_000}}
160diff --git a/internal/adapters/speech.go b/internal/adapters/speech.go
161new file mode 100644
162index 0000000000000000000000000000000000000000..2fabeaf93032bab86a2922e085e5923ef39d114b
163--- /dev/null
164+++ b/internal/adapters/speech.go
165@@ -0,0 +1,61 @@
166+package adapters
167+
168+import (
169+	"context"
170+	"fmt"
171+
172+	"japanese/internal/game"
173+	"japanese/internal/stt"
174+	"japanese/internal/tts"
175+)
176+
177+// SpeechInput adapts the stt recorder, Whisper client, and romanizer to
178+// game.SpeechInput. Begin starts push-to-talk capture; End stops it, uploads
179+// the recording to Whisper, and romanizes the transcript.
180+type SpeechInput struct {
181+	Recorder *stt.Recorder
182+	Whisper  *stt.WhisperClient
183+	Romanize *stt.Romanizer
184+
185+	cur *stt.Recording
186+}
187+
188+func (s *SpeechInput) Begin(ctx context.Context) error {
189+	rec, err := s.Recorder.Start(ctx)
190+	if err != nil {
191+		return fmt.Errorf("mic: %w", err)
192+	}
193+	s.cur = rec
194+	return nil
195+}
196+
197+func (s *SpeechInput) End(ctx context.Context) (game.Transcript, error) {
198+	if s.cur == nil {
199+		return game.Transcript{}, fmt.Errorf("mic: recording not started")
200+	}
201+	path, err := s.cur.Stop()
202+	if err != nil {
203+		return game.Transcript{}, fmt.Errorf("mic: %w", err)
204+	}
205+	raw, err := s.Whisper.Transcribe(ctx, path)
206+	if err != nil {
207+		return game.Transcript{}, err
208+	}
209+	tr := s.Romanize.Romanize(raw)
210+	return game.Transcript{Raw: tr.Raw, Romaji: tr.Romaji}, nil
211+}
212+
213+// SpeechOutput adapts the tts client and player to game.SpeechOutput. It
214+// synthesizes the NPC reply's kana and plays it without blocking.
215+type SpeechOutput struct {
216+	Client *tts.Client
217+	Player *tts.Player
218+}
219+
220+func (s *SpeechOutput) Speak(ctx context.Context, kana string) error {
221+	wav, err := s.Client.Speech(ctx, kana)
222+	if err != nil {
223+		return err
224+	}
225+	return s.Player.Play(wav)
226+}
227diff --git a/internal/adapters/speech_test.go b/internal/adapters/speech_test.go
228new file mode 100644
229index 0000000000000000000000000000000000000000..9a42959e6e1f5befe199b3644de108f8d1500797
230--- /dev/null
231+++ b/internal/adapters/speech_test.go
232@@ -0,0 +1,24 @@
233+package adapters
234+
235+import (
236+	"context"
237+	"net/http"
238+	"net/http/httptest"
239+	"strings"
240+	"testing"
241+
242+	"japanese/internal/tts"
243+)
244+
245+func TestSpeechOutputSpeakErrorNamesURL(t *testing.T) {
246+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247+		http.Error(w, "boom", http.StatusInternalServerError)
248+	}))
249+	defer srv.Close()
250+
251+	out := &SpeechOutput{Client: tts.NewClient(srv.URL, srv.Client()), Player: tts.NewPlayer()}
252+	err := out.Speak(context.Background(), "こんにちは")
253+	if err == nil || !strings.Contains(err.Error(), srv.URL) {
254+		t.Errorf("expected TTS error naming URL %q, got %v", srv.URL, err)
255+	}
256+}
257diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
258new file mode 100644
259index 0000000000000000000000000000000000000000..286bb99a15048d94a7280f67d8016cfc2c1264a3
260--- /dev/null
261+++ b/internal/game/orchestrator.go
262@@ -0,0 +1,175 @@
263+package game
264+
265+import (
266+	"context"
267+	"errors"
268+	"sync"
269+
270+	"japanese/internal/llm"
271+)
272+
273+// Transcript is a captured spoken line: the original text (for LLM prompts)
274+// and its deterministic romaji form (for display).
275+type Transcript struct {
276+	Raw    string
277+	Romaji string
278+}
279+
280+// SpeechInput captures push-to-talk audio and returns a romanized transcript.
281+// Begin starts capture; End stops it, transcribes, and romanizes.
282+type SpeechInput interface {
283+	Begin(ctx context.Context) error
284+	End(ctx context.Context) (Transcript, error)
285+}
286+
287+// NPCRequest carries everything the NPC model needs for one reply. Judge output
288+// never appears in it.
289+type NPCRequest struct {
290+	Persona    string     // stable persona description
291+	Situation  string     // local situation text
292+	Transcript string     // raw transcript (Japanese)
293+	History    []llm.Turn // prior turns; the adapter bounds this
294+}
295+
296+// NPCModel produces one NPC reply for a request.
297+type NPCModel interface {
298+	Reply(ctx context.Context, req NPCRequest) (llm.NPCReply, error)
299+}
300+
301+// JudgeRequest carries what the judge needs to score one spoken line.
302+type JudgeRequest struct {
303+	Situation  string
304+	Transcript string // raw transcript
305+}
306+
307+// JudgeModel scores a spoken line. It is independent of the NPC model.
308+type JudgeModel interface {
309+	Judge(ctx context.Context, req JudgeRequest) (llm.JudgeResult, error)
310+}
311+
312+// SpeechOutput synthesizes and plays an NPC reply's audio from its kana.
313+type SpeechOutput interface {
314+	Speak(ctx context.Context, kana string) error
315+}
316+
317+// PersonaView is the stable persona data the orchestrator sends to the models
318+// for one location: a composed description and the local situation text.
319+type PersonaView struct {
320+	Description string
321+	Situation   string
322+}
323+
324+// ErrNotInLocation is returned when a turn is finished outside any location.
325+var ErrNotInLocation = errors.New("game: not in a location")
326+
327+// TurnResult reports what happened in one turn. A non-nil Err means no NPC
328+// reply was produced, so nothing was recorded to history. JudgeErr and SpeakErr
329+// are recoverable: they do not prevent recording and are surfaced separately.
330+type TurnResult struct {
331+	Transcript Transcript
332+	Judge      llm.JudgeResult
333+	JudgeErr   error
334+	NPC        llm.NPCReply
335+	SpeakErr   error
336+	Err        error
337+}
338+
339+// Orchestrator runs the turn flow over the adapter interfaces, keeping all game
340+// state in the embedded State. It does no I/O itself; every adapter call is an
341+// explicit await on the caller's context, so it is safe to run off a UI event
342+// loop (for example from a Bubble Tea command goroutine).
343+type Orchestrator struct {
344+	state     *State
345+	personas  map[string]PersonaView
346+	speechIn  SpeechInput
347+	npc       NPCModel
348+	judge     JudgeModel
349+	speechOut SpeechOutput
350+}
351+
352+// NewOrchestrator wires an orchestrator over a state and the four adapters.
353+func NewOrchestrator(state *State, personas map[string]PersonaView, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) *Orchestrator {
354+	return &Orchestrator{
355+		state:     state,
356+		personas:  personas,
357+		speechIn:  in,
358+		npc:       npc,
359+		judge:     judge,
360+		speechOut: out,
361+	}
362diff --git a/internal/game/turn_test.go b/internal/game/turn_test.go
363new file mode 100644
364index 0000000000000000000000000000000000000000..e6c60f3d83df9d0620a64cc81c0022281b2a295b
365--- /dev/null
366+++ b/internal/game/turn_test.go
367@@ -0,0 +1,301 @@
368+package game
369+
370+import (
371+	"context"
372+	"errors"
373+	"fmt"
374+	"strings"
375+	"sync"
376+	"testing"
377+	"time"
378+
379+	"japanese/internal/llm"
380+)
381+
382+type fakeInput struct {
383+	transcript Transcript
384+	endErr     error
385+}
386+
387+func (f *fakeInput) Begin(ctx context.Context) error { return nil }
388+func (f *fakeInput) End(ctx context.Context) (Transcript, error) {
389+	return f.transcript, f.endErr
390+}
391+
392+type fakeNPC struct {
393+	reply   llm.NPCReply
394+	err     error
395+	lastReq NPCRequest
396+	got     bool
397+}
398+
399+func (f *fakeNPC) Reply(ctx context.Context, req NPCRequest) (llm.NPCReply, error) {
400+	f.lastReq = req
401+	f.got = true
402+	return f.reply, f.err
403+}
404+
405+type fakeJudge struct {
406+	result llm.JudgeResult
407+	err    error
408+}
409+
410+func (f *fakeJudge) Judge(ctx context.Context, req JudgeRequest) (llm.JudgeResult, error) {
411+	return f.result, f.err
412+}
413+
414+type fakeOut struct {
415+	spoken []string
416+	err    error
417+}
418+
419+func (f *fakeOut) Speak(ctx context.Context, kana string) error {
420+	if f.err != nil {
421+		return f.err
422+	}
423+	f.spoken = append(f.spoken, kana)
424+	return nil
425+}
426+
427+// newOrchAtStation builds an orchestrator whose state is standing at the
428+// station location.
429+func newOrchAtStation(t *testing.T, in SpeechInput, npc NPCModel, judge JudgeModel, out SpeechOutput) (*Orchestrator, *State) {
430+	t.Helper()
431+	m, err := ParseMap([]byte(validMapJSON))
432+	if err != nil {
433+		t.Fatalf("ParseMap: %v", err)
434+	}
435+	s := NewState(m)
436+	if !s.Move(Down) || !s.Move(Right) {
437+		t.Fatalf("could not reach station")
438+	}
439+	personas := map[string]PersonaView{
440+		"station": {Description: "a careful clerk", Situation: "at a ticket window"},
441+	}
442+	return NewOrchestrator(s, personas, in, npc, judge, out), s
443+}
444+
445+func TestHappyTurnEndToEnd(t *testing.T) {
446+	in := &fakeInput{transcript: Transcript{Raw: "raw-line", Romaji: "ro-ma-ji"}}
447+	npc := &fakeNPC{reply: llm.NPCReply{Romaji: "romaji-reply", Kana: "kana-reply", English: "english-reply"}}
448+	judge := &fakeJudge{result: llm.JudgeResult{Score: 80, Feedback: "good"}}
449+	out := &fakeOut{}
450+
451+	o, s := newOrchAtStation(t, in, npc, judge, out)
452+	res := o.Finish(context.Background())
453+
454+	if res.Err != nil {
455+		t.Fatalf("turn error: %v", res.Err)
456+	}
457+	if res.Transcript.Raw != "raw-line" || res.Transcript.Romaji != "ro-ma-ji" {
458+		t.Errorf("transcript = %+v", res.Transcript)
459+	}
460+	if res.JudgeErr != nil || res.Judge.Score != 80 {
461+		t.Errorf("judge = %+v err=%v", res.Judge, res.JudgeErr)
462+	}
463+	if res.NPC.Romaji != "romaji-reply" || res.SpeakErr != nil {
464+		t.Errorf("npc/speak = %+v err=%v", res.NPC, res.SpeakErr)
465+	}
466+	if len(out.spoken) != 1 || out.spoken[0] != "kana-reply" {