ecfd15acf761dc7ced14dd669de69862f1fdfdd2

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

Message

ui: add bubble tea shell and map view

Wire jp run to the real game: load config, probe service availability,
load the city map and personas, build the adapters plus game state and
orchestrator, then launch a non-blocking Bubble Tea app. The map view
renders the grid with the player marker and per-location legend; movement
drives State.Move (arrows/WASD). All long-running work is deferred to
tea.Cmd workers so the event loop never blocks.

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index d158006f50bc8adb70fbdd78256cde434c693551..5c020cd77cfbc93ce46217e09022c30c5125fa6d 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -7,13 +7,21 @@ import (
  6 	"flag"
  7 	"fmt"
  8 	"io"
  9+	"net/http"
 10 	"os"
 11 	"time"
 12 
 13+	"github.com/charmbracelet/bubbletea"
 14+
 15+	"japanese/internal/adapters"
 16 	"japanese/internal/availability"
 17 	"japanese/internal/config"
 18+	"japanese/internal/game"
 19 	"japanese/internal/llm"
 20 	"japanese/internal/persona"
 21+	"japanese/internal/stt"
 22+	"japanese/internal/tts"
 23+	"japanese/internal/ui"
 24 )
 25 
 26 func main() {
 27@@ -35,22 +43,86 @@ func main() {
 28 	}
 29 }
 30 
 31+// recordCap bounds a single push-to-talk capture. It is passed to the recorder
 32+// (which enforces it) and to the UI (which shows it as the cap indicator).
 33+const recordCap = 10 * time.Second
 34+
 35 func runCmd(args []string) {
 36 	fs := flag.NewFlagSet("run", flag.ExitOnError)
 37 	var cfg config.Config
 38 	cfg.RegisterFlags(fs)
 39+	mapPath := fs.String("map", "assets/maps/city.json", "path to the city map JSON")
 40+	personasDir := fs.String("personas", "assets/personas", "directory of persona JSON files")
 41 	fs.Parse(args)
 42-	reportServiceStatus(cfg)
 43-	fmt.Fprintln(os.Stderr, "jp run: not implemented yet")
 44+
 45+	services := probeServices(cfg)
 46+
 47+	data, err := os.ReadFile(*mapPath)
 48+	if err != nil {
 49+		fatalf("read map: %v", err)
 50+	}
 51+	mapData, err := game.ParseMap(data)
 52+	if err != nil {
 53+		fatalf("load map: %v", err)
 54+	}
 55+	personas, err := persona.LoadDir(*personasDir)
 56+	if err != nil {
 57+		fatalf("load personas: %v", err)
 58+	}
 59+
 60+	state := game.NewState(mapData)
 61+	orch := buildOrchestrator(cfg, state, personas)
 62+
 63+	m := ui.NewModel(state, orch, services, int(recordCap.Seconds()))
 64+	if _, err := tea.NewProgram(m).Run(); err != nil {
 65+		fatalf("run: %v", err)
 66+	}
 67 }
 68 
 69-func reportServiceStatus(cfg config.Config) {
 70-	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 71+func probeServices(cfg config.Config) []availability.Service {
 72+	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
 73 	defer cancel()
 74-	fmt.Fprintln(os.Stderr, "service status:")
 75-	for _, s := range availability.CheckAll(ctx, cfg) {
 76-		fmt.Fprintln(os.Stderr, "  "+s.String())
 77+	return availability.CheckAll(ctx, cfg)
 78+}
 79+
 80+func buildOrchestrator(cfg config.Config, state *game.State, personas []persona.Persona) *game.Orchestrator {
 81+	hc := &http.Client{Timeout: 90 * time.Second}
 82+	llmClient := llm.NewClient(cfg.LLMBaseURL, hc)
 83+	npcModel := &adapters.NPCModel{Client: llmClient, Policy: llm.DefaultHistoryPolicy()}
 84+	judgeModel := &adapters.JudgeModel{Client: llmClient}
 85+
 86+	romanizer, err := stt.NewRomanizer()
 87+	if err != nil {
 88+		fatalf("load romanizer: %v", err)
 89+	}
 90+	speechIn := &adapters.SpeechInput{
 91+		Recorder: stt.NewRecorder(cfg.RecordCommand, recordCap),
 92+		Whisper:  stt.NewWhisperClient(cfg.STTURL, cfg.STTLanguage, hc),
 93+		Romanize: romanizer,
 94 	}
 95+	speechOut := &adapters.SpeechOutput{
 96+		Client: tts.NewClient(cfg.TTSBaseURL, hc),
 97+		Player: tts.NewPlayer(),
 98+	}
 99+
100+	return game.NewOrchestrator(state, buildPersonaViews(personas), speechIn, npcModel, judgeModel, speechOut)
101+}
102+
103+func buildPersonaViews(personas []persona.Persona) map[string]game.PersonaView {
104+	views := make(map[string]game.PersonaView, len(personas))
105diff --git a/go.mod b/go.mod
106index 9814969f4c072eda6aeed5c4c425abfb95b96110..7e76705a4c6700f26b6001b8fa445bdacc6e77de 100644
107--- a/go.mod
108+++ b/go.mod
109@@ -9,8 +9,26 @@ require (
110 )
111 
112 require (
113+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
114+	github.com/charmbracelet/bubbletea v1.3.10 // indirect
115+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
116+	github.com/charmbracelet/lipgloss v1.1.0 // indirect
117+	github.com/charmbracelet/x/ansi v0.10.1 // indirect
118+	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
119+	github.com/charmbracelet/x/term v0.2.1 // indirect
120 	github.com/ebitengine/purego v0.11.0 // indirect
121+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
122 	github.com/ikawaha/kagome-dict v1.1.7 // indirect
123 	github.com/jfreymuth/pulse v0.1.3 // indirect
124+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
125+	github.com/mattn/go-isatty v0.0.20 // indirect
126+	github.com/mattn/go-localereader v0.0.1 // indirect
127+	github.com/mattn/go-runewidth v0.0.16 // indirect
128+	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
129+	github.com/muesli/cancelreader v0.2.2 // indirect
130+	github.com/muesli/termenv v0.16.0 // indirect
131+	github.com/rivo/uniseg v0.4.7 // indirect
132+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
133 	golang.org/x/sys v0.47.0 // indirect
134+	golang.org/x/text v0.32.0 // indirect
135 )
136diff --git a/go.sum b/go.sum
137index a44f8585d3c71a8fb7db3a4edf6003102143ca71..d82ca688ab092f03ea165aaf92cbbcdc84caffa0 100644
138--- a/go.sum
139+++ b/go.sum
140@@ -1,7 +1,23 @@
141+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
142+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
143+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
144+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
145+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
146+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
147+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
148+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
149+github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
150+github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
151+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
152+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
153+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
154+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
155 github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GPXs=
156 github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o=
157 github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
158 github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
159+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
160+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
161 github.com/ikawaha/kagome-dict v1.1.7 h1:O/uAL+WCGhp6kT0+szxBSPaSM4i+vdArSefFvJE4Nug=
162 github.com/ikawaha/kagome-dict v1.1.7/go.mod h1:9tvk7/jZkvYt40foxkB9CqSAAknoQrIPfzqQd05UkFw=
163 github.com/ikawaha/kagome-dict/ipa v1.2.6 h1:Bcvm4jgxAAnTIKb6ckqUKBiFDN0wuanFfycMuYt7xGQ=
164@@ -10,5 +26,28 @@ github.com/ikawaha/kagome/v2 v2.11.0 h1:R914EkRzay9qtUbsFzEbcdZ3wHwwSPvbPkuBI1oI
165 github.com/ikawaha/kagome/v2 v2.11.0/go.mod h1:6mYPezBou+iNVnX9uNa00Sfu6S6t2zcM8Nv1EW9Y9so=
166 github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
167 github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
168+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
169+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
170+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
171+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
172+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
173+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
174+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
175+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
176+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
177+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
178+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
179+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
180+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
181+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
182+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
183+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
184+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
185+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
186+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
187+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
188+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
189 golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
190 golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
191+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
192+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
193diff --git a/internal/ui/app.go b/internal/ui/app.go
194new file mode 100644
195index 0000000000000000000000000000000000000000..f0777cc02e477b9eabd554862d0b169213433b7d
196--- /dev/null
197+++ b/internal/ui/app.go
198@@ -0,0 +1,84 @@
199+package ui
200+
201+import (
202+	"github.com/charmbracelet/bubbletea"
203+
204+	"japanese/internal/availability"
205+	"japanese/internal/game"
206+)
207+
208+type model struct {
209+	state        *game.State
210+	orch         *game.Orchestrator
211+	services     []availability.Service
212+	recordCapSec int
213+	status       string
214+}
215+
216+func NewModel(state *game.State, orch *game.Orchestrator, services []availability.Service, recordCapSec int) *model {
217+	return &model{
218+		state:        state,
219+		orch:         orch,
220+		services:     services,
221+		recordCapSec: recordCapSec,
222+		status:       "move: arrows / WASD    q: quit",
223+	}
224+}
225+
226+func (m *model) Init() tea.Cmd { return nil }
227+
228+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
229+	k, ok := msg.(tea.KeyMsg)
230+	if !ok {
231+		return m, nil
232+	}
233+	switch {
234+	case k.Type == tea.KeyEsc || isRune(k, 'q'):
235+		return m, tea.Quit
236+	case k.Type == tea.KeyUp || isRune(k, 'w'):
237+		m.state.Move(game.Up)
238+	case k.Type == tea.KeyDown || isRune(k, 's'):
239+		m.state.Move(game.Down)
240+	case k.Type == tea.KeyLeft || isRune(k, 'a'):
241+		m.state.Move(game.Left)
242+	case k.Type == tea.KeyRight || isRune(k, 'd'):
243+		m.state.Move(game.Right)
244+	}
245+	return m, nil
246+}
247+
248+func (m *model) View() string {
249+	st := m.state
250+	mp := st.Map()
251+	pos := st.Pos()
252+	vs := viewState{
253+		mapRows:  mp.Rows,
254+		posRow:   pos.Row,
255+		posCol:   pos.Col,
256+		activeID: st.ActiveLocation(),
257+		locs:     locLines(mp),
258+		services: serviceLines(m.services),
259+		status:   m.status,
260+	}
261+	return render(vs)
262+}
263+
264+func isRune(k tea.KeyMsg, r rune) bool {
265+	return k.Type == tea.KeyRunes && len(k.Runes) == 1 && k.Runes[0] == r
266+}
267+
268+func locLines(mp game.MapData) []locLine {
269+	out := make([]locLine, 0, len(mp.Locations))
270+	for _, l := range mp.Locations {
271+		out = append(out, locLine{id: l.ID, marker: l.Marker, label: l.Label})
272+	}
273+	return out
274+}
275+
276+func serviceLines(svcs []availability.Service) []serviceLine {
277+	out := make([]serviceLine, 0, len(svcs))
278+	for _, s := range svcs {
279+		out = append(out, serviceLine{name: s.Name, up: s.Up, url: s.URL, detail: s.Detail})
280+	}
281+	return out
282+}
283diff --git a/internal/ui/render.go b/internal/ui/render.go
284new file mode 100644
285index 0000000000000000000000000000000000000000..1a9e27170255ea3ff63901088138697a11e5726a
286--- /dev/null
287+++ b/internal/ui/render.go
288@@ -0,0 +1,98 @@
289+// Package ui is the Bubble Tea front end. It renders romaji only and keeps all
290+// long-running work off the event loop by running it in tea.Cmd workers that
291+// return results as tea.Msg values.
292+package ui
293+
294+import (
295+	"fmt"
296+	"strings"
297+)
298+
299+type locLine struct {
300+	id     string
301+	marker string
302+	label  string
303+}
304+
305+type serviceLine struct {
306+	name   string
307+	up     bool
308+	url    string
309+	detail string
310+}
311+
312+// viewState is the plain, render-only snapshot the pure render functions take.
313+// It holds no pointers and no game types, so rendering is trivially pure.
314+type viewState struct {
315+	mapRows  []string
316+	posRow   int
317+	posCol   int
318+	activeID string
319+	locs     []locLine
320+
321+	services []serviceLine
322+	status   string
323+}
324+
325+func render(vs viewState) string {
326+	var b strings.Builder
327+	b.WriteString(mapBlock(vs.mapRows, vs.posRow, vs.posCol))
328+	if line := legendBlock(vs.locs, vs.activeID); line != "" {
329+		b.WriteString(line)
330+		b.WriteByte('\n')
331+	}
332+	b.WriteString(serviceBlock(vs.services))
333+	b.WriteByte('\n')
334+	b.WriteString(vs.status)
335+	return b.String()
336+}
337+
338+func mapBlock(rows []string, posRow, posCol int) string {
339+	var b strings.Builder
340+	for r, row := range rows {
341+		for c, ch := range row {
342+			if r == posRow && c == posCol {
343+				b.WriteRune('@')
344+			} else {
345+				b.WriteRune(ch)
346+			}
347+		}
348+		b.WriteByte('\n')
349+	}
350+	return b.String()
351+}
352+
353+func legendBlock(locs []locLine, activeID string) string {
354+	if len(locs) == 0 {
355+		return ""
356+	}
357+	var b strings.Builder
358+	b.WriteString("Locations: ")
359+	for i, l := range locs {
360+		mark := " "
361+		if l.id == activeID {
362+			mark = "*"
363+		}
364+		fmt.Fprintf(&b, "%s%s %s", mark, l.marker, l.label)
365+		if i < len(locs)-1 {
366+			b.WriteString("   ")
367+		}
368+	}
369+	return b.String()
370+}
371+
372+func serviceBlock(svcs []serviceLine) string {
373+	var b strings.Builder
374+	for _, s := range svcs {
375+		state := "down"
376+		if s.up {
377+			state = "up"
378+		}
379+		fmt.Fprintf(&b, "%s %s %s", s.name, state, s.url)
380+		if s.detail != "" {
381+			fmt.Fprintf(&b, "  (%s)", s.detail)
382+		}
383+		b.WriteByte('\n')
384+	}
385+	return b.String()
386+}