dd9e08ba2f131646a0b8ebb77bc51a584b5930b7

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

Message

ui: add reveal keys and romaji-only rendering guard

R reveals the current turn's romaji, T then reveals its stored English; the
order is enforced by game.State so T before R is a no-op. A fail-safe
sanitizeRomajiOnly runs at every view boundary (transcript, NPC text, feedback,
errors, map labels, status) so kana/kanji can never render even if an upstream
field carries Japanese. Tests cover reveal ordering, the push-to-talk state
machine, and hostile-input rendering with no kana/kanji in output.

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