dd9e08ba2f131646a0b8ebb77bc51a584b5930b7
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/internal/ui/app.go b/internal/ui/app.go
2index 0f0d3f900e8221d3ca29aa185749c212ae8822dd..d44f26c444284090963c6ce10f5bbf6f4a046213 100644
3--- a/internal/ui/app.go
4+++ b/internal/ui/app.go
5@@ -35,8 +35,9 @@ type model struct {
6 services []availability.Service
7 recordCapSec int
8
9- phase talkPhase
10- recSince time.Time
11+ phase talkPhase
12+ beginning bool
13+ recSince time.Time
14
15 transcriptRomaji string
16 hasJudge bool
17@@ -91,12 +92,32 @@ func (m *model) handleKey(k tea.KeyMsg) tea.Cmd {
18 m.tryMove(game.Left)
19 case k.Type == tea.KeyRight || isRune(k, 'd'):
20 m.tryMove(game.Right)
21+ case isRune(k, 'r'):
22+ m.tryRevealRomaji()
23+ case isRune(k, 't'):
24+ m.tryRevealEnglish()
25 case isSpaceOrEnter(k):
26 return m.talkToggle()
27 }
28 return nil
29 }
30
31+// tryRevealRomaji / tryRevealEnglish advance the reveal order on the active
32+// location's latest reply. game.State enforces romaji-before-english; a T before
33+// R is a no-op there. Both are gated to idle so they never race a worker that is
34+// recording history.
35+func (m *model) tryRevealRomaji() {
36+ if m.phase == phaseIdle {
37+ m.state.RevealRomaji()
38+ }
39+}
40+
41+func (m *model) tryRevealEnglish() {
42+ if m.phase == phaseIdle {
43+ m.state.RevealEnglish()
44+ }
45+}
46+
47 // tryMove only acts when idle: the player stands still while talking and while a
48 // turn is in flight. This also keeps State off-limits to the event loop during
49 // the one window (processing) where a worker mutates it.
50@@ -114,9 +135,15 @@ func (m *model) talkToggle() tea.Cmd {
51 return nil
52 }
53 m.phase = phaseRecording
54+ m.beginning = true
55 m.recSince = time.Now()
56 return tea.Batch(beginTurn(m.orch), nextTick())
57 case phaseRecording:
58+ // Ignore stop while Begin is still in flight so Begin and End never run
59+ // concurrently against the shared recorder.
60+ if m.beginning {
61+ return nil
62+ }
63 m.phase = phaseProcessing
64 return finishTurn(m.orch)
65 }
66@@ -124,6 +151,7 @@ func (m *model) talkToggle() tea.Cmd {
67 }
68
69 func (m *model) applyBegin(err error) {
70+ m.beginning = false
71 if err != nil {
72 m.phase = phaseIdle
73 m.status = "recording failed: " + err.Error()
74@@ -156,6 +184,7 @@ func (m *model) applyFinish(res game.TurnResult) {
75 m.npcStatus = fmt.Sprintf("TTS failed (%s): %v", m.svcURL("TTS"), res.SpeakErr)
76 default:
77 m.npcStatus = "speaking"
78+ m.status = ""
79 }
80 m.phase = phaseIdle
81 }
82@@ -165,22 +194,22 @@ func (m *model) View() string {
83 mp := st.Map()
84 pos := st.Pos()
85 vs := viewState{
86- mapRows: mp.Rows,
87- posRow: pos.Row,
88- posCol: pos.Col,
89- activeID: st.ActiveLocation(),
90- locs: locLines(mp),
91- phaseWord: m.phaseWord(),
92- recElapsedSec: elapsed(m.recSince),
93- recCapSec: m.recordCapSec,
94+ mapRows: mp.Rows,
95+ posRow: pos.Row,
96+ posCol: pos.Col,
97+ activeID: st.ActiveLocation(),
98+ locs: locLines(mp),
99+ phaseWord: m.phaseWord(),
100+ recElapsedSec: elapsed(m.recSince),
101+ recCapSec: m.recordCapSec,
102 transcriptRomaji: m.transcriptRomaji,
103- npcStatus: m.npcStatus,
104- hasJudge: m.hasJudge,
105diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go
106index a170a93bfa5448185aa4fb1a3e70e2c9d0620c8d..071ccca50c3075599905daf1fd002a95baa4f536 100644
107--- a/internal/ui/app_test.go
108+++ b/internal/ui/app_test.go
109@@ -3,6 +3,7 @@ package ui
110 import (
111 "context"
112 "errors"
113+ "strings"
114 "testing"
115
116 "github.com/charmbracelet/bubbletea"
117@@ -126,3 +127,86 @@ func TestFinishWorkerRunsTurn(t *testing.T) {
118 t.Fatalf("unexpected turn result: %+v", fr.res)
119 }
120 }
121+
122+func TestStopIgnoredWhileBeginInFlight(t *testing.T) {
123+ m, _ := buildModel(t)
124+ if _, cmd := m.Update(spaceKey()); m.phase != phaseRecording || cmd == nil {
125+ t.Fatalf("start failed: phase=%v", m.phase)
126+ }
127+ // Begin still in flight: stop must be ignored (no concurrent Begin/End).
128+ if _, cmd := m.Update(spaceKey()); m.phase != phaseRecording || cmd != nil {
129+ t.Fatalf("stop during begin should be ignored; phase=%v cmd=%v", m.phase, cmd == nil)
130+ }
131+ // After Begin returns, stop proceeds.
132+ if _, _ = m.Update(beginResultMsg{err: nil}); m.phase != phaseRecording {
133+ t.Fatalf("phase=%v; want recording", m.phase)
134+ }
135+ if _, cmd := m.Update(spaceKey()); m.phase != phaseProcessing || cmd == nil {
136+ t.Fatalf("stop after begin should process; phase=%v", m.phase)
137+ }
138+}
139+
140+func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
141+
142+func TestRevealOrdering(t *testing.T) {
143+ m, st := buildModel(t)
144+ st.RecordTurn("ramen", "raw", "ra", game.Reply{Romaji: "secret-romaji", English: "secret-english"})
145+
146+ if v := m.View(); strings.Contains(v, "secret-romaji") || strings.Contains(v, "secret-english") {
147+ t.Fatalf("hidden text leaked before any reveal:\n%s", v)
148+ }
149+
150+ // T before R must be a no-op.
151+ if _, _ = m.Update(runeKey('t')); strings.Contains(m.View(), "secret-english") {
152+ t.Fatalf("T before R revealed english; want no-op")
153+ }
154+
155+ // R reveals romaji only.
156+ m.Update(runeKey('r'))
157+ if v := m.View(); !strings.Contains(v, "secret-romaji") || strings.Contains(v, "secret-english") {
158+ t.Fatalf("after R want romaji only, got:\n%s", v)
159+ }
160+
161+ // T then reveals the stored english (no second translation request).
162+ if _, _ = m.Update(runeKey('t')); !strings.Contains(m.View(), "secret-english") {
163+ t.Fatalf("after T want english revealed")
164+ }
165+}
166+
167+func TestRenderNeverShowsJapanese(t *testing.T) {
168+ good := viewState{
169+ mapRows: []string{"###", "#A#", "###"}, posRow: 1, posCol: 1, activeID: "ramen",
170+ locs: []locLine{{id: "ramen", marker: "A", label: "Ramen shop"}},
171+ transcriptRomaji: "konnichiwa desu",
172+ npcStatus: "speaking", romajiShown: true, englishShown: true,
173+ npcRomaji: "arigatou gozaimasu",
174+ npcEnglish: "thank you very much",
175+ hasJudge: true, judgeScore: 90, judgeFeedback: "good job desu",
176+ services: []serviceLine{{name: "LLM", up: true, url: "http://127.0.0.1:8081/v1"}},
177+ }
178+ got := render(good)
179+ for _, want := range []string{"konnichiwa desu", "arigatou gozaimasu", "thank you very much", "good job desu"} {
180+ if !strings.Contains(got, want) {
181+ t.Fatalf("rendered romaji view missing %q:\n%s", want, got)
182+ }
183+ }
184+
185+ hostile := good
186+ hostile.transcriptRomaji = "こんにちは"
187+ hostile.npcRomaji = "ありがとう"
188+ hostile.npcEnglish = "thanks 日本語"
189+ hostile.judgeFeedback = "よくできました"
190+ hostile.locs[0].label = "ラーメン屋"
191+ hostile.status = "エラー occurred"
192+ hostile.services = []serviceLine{{name: "STT", up: false, url: "http://127.0.0.1:8178/inference", detail: "接続できない"}}
193+ assertNoJapanese(t, render(hostile))
194+}
195+
196+func assertNoJapanese(t *testing.T, s string) {
197+ t.Helper()
198+ for _, r := range s {
199+ if isJapaneseRune(r) {
200+ t.Fatalf("rendered output contains Japanese rune U+%04X:\n%s", r, s)
201+ }
202+ }
203+}
204diff --git a/internal/ui/render.go b/internal/ui/render.go
205index d6a7e136cf06370569942bebf690d396f079e3b4..c38561c785b8ef80e01b97397714aeb25f89f0b4 100644
206--- a/internal/ui/render.go
207+++ b/internal/ui/render.go
208@@ -22,7 +22,7 @@ type serviceLine struct {
209 }
210
211 // controlsHint is the idle status line. It only lists keys wired in this build.
212-const controlsHint = "move: arrows / WASD Space: talk q: quit"
213+const controlsHint = "move: arrows / WASD Space: talk R: romaji T: english q: quit"
214
215 // viewState is the plain, render-only snapshot the pure render functions take.
216 // It holds no pointers and no game types, so rendering is trivially pure.
217@@ -71,13 +71,15 @@ func render(vs viewState) string {
218 func mapBlock(rows []string, posRow, posCol int) string {
219 var b strings.Builder
220 for r, row := range rows {
221+ line := make([]rune, 0, len(row))
222 for c, ch := range row {
223 if r == posRow && c == posCol {
224- b.WriteRune('@')
225+ line = append(line, '@')
226 } else {
227- b.WriteRune(ch)
228+ line = append(line, ch)
229 }
230 }
231+ b.WriteString(sanitizeRomajiOnly(string(line)))
232 b.WriteByte('\n')
233 }
234 return b.String()
235@@ -94,7 +96,7 @@ func legendBlock(locs []locLine, activeID string) string {
236 if l.id == activeID {
237 mark = "*"
238 }
239- fmt.Fprintf(&b, "%s%s %s", mark, l.marker, l.label)
240+ fmt.Fprintf(&b, "%s%s %s", mark, sanitizeRomajiOnly(l.marker), sanitizeRomajiOnly(l.label))
241 if i < len(locs)-1 {
242 b.WriteString(" ")
243 }
244@@ -105,13 +107,13 @@ func legendBlock(locs []locLine, activeID string) string {
245 func dialogueBlock(transcript, npcStatus string, romajiShown, englishShown bool, npcRomaji, npcEnglish string) string {
246 var b strings.Builder
247 b.WriteString("Dialogue\n")
248- fmt.Fprintf(&b, " You said: %s\n", valueOrNone(transcript))
249- fmt.Fprintf(&b, " NPC: %s\n", valueOrNone(npcStatus))
250+ fmt.Fprintf(&b, " You said: %s\n", valueOrNone(sanitizeRomajiOnly(transcript)))
251+ fmt.Fprintf(&b, " NPC: %s\n", valueOrNone(sanitizeRomajiOnly(npcStatus)))
252 if romajiShown {
253- fmt.Fprintf(&b, " ROMAJI: %s\n", valueOrNone(npcRomaji))
254+ fmt.Fprintf(&b, " ROMAJI: %s\n", valueOrNone(sanitizeRomajiOnly(npcRomaji)))
255 }
256 if englishShown {
257- fmt.Fprintf(&b, " ENGLISH: %s\n", valueOrNone(npcEnglish))
258+ fmt.Fprintf(&b, " ENGLISH: %s\n", valueOrNone(sanitizeRomajiOnly(npcEnglish)))
259 }
260 return b.String()
261 }
262@@ -120,10 +122,10 @@ func learningBlock(hasJudge bool, score int, feedback, judgeErr string) string {
263 var b strings.Builder
264 b.WriteString("Learning\n")
265 if judgeErr != "" {
266- fmt.Fprintf(&b, " %s\n", judgeErr)
267+ fmt.Fprintf(&b, " %s\n", sanitizeRomajiOnly(judgeErr))
268 } else if hasJudge {
269 fmt.Fprintf(&b, " Score: %d/100\n", score)
270- fmt.Fprintf(&b, " Feedback: %s\n", valueOrNone(feedback))
271+ fmt.Fprintf(&b, " Feedback: %s\n", valueOrNone(sanitizeRomajiOnly(feedback)))
272 } else {
273 b.WriteString(" (no feedback yet)\n")
274 }
275@@ -137,9 +139,9 @@ func serviceBlock(svcs []serviceLine) string {
276 if s.up {
277 state = "up"
278 }
279- fmt.Fprintf(&b, "%s %s %s", s.name, state, s.url)
280+ fmt.Fprintf(&b, "%s %s %s", sanitizeRomajiOnly(s.name), state, sanitizeRomajiOnly(s.url))
281 if s.detail != "" {
282- fmt.Fprintf(&b, " (%s)", s.detail)
283+ fmt.Fprintf(&b, " (%s)", sanitizeRomajiOnly(s.detail))
284 }
285 b.WriteByte('\n')
286 }
287@@ -154,7 +156,7 @@ func statusLine(base, phaseWord string, recElapsedSec, recCapSec int) string {
288 return "Processing turn…"
289 }
290 if base != "" {
291- return base + " " + controlsHint
292+ return sanitizeRomajiOnly(base) + " " + controlsHint
293 }
294 return controlsHint
295 }
296@@ -165,3 +167,41 @@ func valueOrNone(s string) string {
297 }
298 return s
299 }
300+
301+// isJapaneseRune reports whether r is a kana or CJK ideograph. The UI must never
302+// render these; the only Japanese that may exist at runtime is inside worker
303+// goroutines for LLM/TTS prompts, which is never passed to the view.
304+func isJapaneseRune(r rune) bool {
305+ return (r >= 0x3041 && r <= 0x309F) || // hiragana
306+ (r >= 0x30A0 && r <= 0x30FF) || // katakana (incl. long-vowel mark)
307+ (r >= 0x4E00 && r <= 0x9FFF) || // CJK unified ideographs