ecfd15acf761dc7ced14dd669de69862f1fdfdd2
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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+}