20ca0ac5c522995d03fd944c20477d3dabdd0cc2

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

Message

Improve game flow prompt, show player transcript, add colored output

- Rewrite game system prompt: typed English is a world instruction (never
  NPC-directed speech), waiting fast-forwards, English questions are
  meta-questions answered in narration, NPC speech matches described state
- Remove proper names from scenario personas; refer by role/appearance
- Show player's spoken romaji transcript above judge score in display
- Color-code output: cyan (action), green (player speech), yellow (judge),
  gray (key hints)
- Remove dead code: NPCReply, Turn, BoundHistory, old NPC prompt builder
- Update AGENTS.md dev instructions for golangci-lint

Diff

This diff is truncated to protect this page.

  1diff --git a/AGENTS.md b/AGENTS.md
  2index 0bbed30d9d19c738bade143915cbc3d10a91b8bc..10e6bd5c98ac4e7776ffadfd0e835c2d34d8a181 100644
  3--- a/AGENTS.md
  4+++ b/AGENTS.md
  5@@ -57,8 +57,12 @@ almost fully allocated to the chat model.
  6 
  7 ## Development
  8 
  9-- Verify changes with `go build ./...` and `go vet ./...`. Do not write any
 10-  tests; there is no test suite in this project.
 11+- Clean out dead code by default: when a change leaves functions, types,
 12+  fields, or imports unused, remove them — including anything newly orphaned
 13+  by that removal.
 14+- Verify changes with `go build ./...`, `golangci-lint fmt ./...`, and
 15+  `golangci-lint run ./...`. Do not write any tests; there is no test suite in
 16+  this project.
 17 - Only the user may prepare live services for manual integration.
 18 - Docs: `README.md` covers running and configuration only. Operator reference
 19   for the external services lives in `docs/services.md`.
 20diff --git a/assets/scenarios/city.json b/assets/scenarios/city.json
 21index eb8bf01aa7d49a2be21ca250e98cac66da775c94..42ab1491e9ead66edeed40f2ad790e8d96a91bf5 100644
 22--- a/assets/scenarios/city.json
 23+++ b/assets/scenarios/city.json
 24@@ -1,4 +1,4 @@
 25 {
 26   "name": "city",
 27diff --git a/internal/adapters/llm.go b/internal/adapters/llm.go
 28index b03da5ac6d9078bf8c7d82dc8bea915da2f24dec..378a097f779c095aa86459980c036cf15cfbd051 100644
 29--- a/internal/adapters/llm.go
 30+++ b/internal/adapters/llm.go
 31@@ -24,8 +24,8 @@ func dumpRaw(label, transcript, raw string) {
 32 	if err != nil {
 33 		return
 34 	}
 35-	defer f.Close()
 36-	fmt.Fprintf(f, "--- %s %s ---\ntranscript: %s\n%s\n\n", label, time.Now().Format(time.RFC3339), transcript, raw)
 37+	defer f.Close()                                                                                                  //nolint:errcheck // best-effort debug log; never block the turn
 38+	fmt.Fprintf(f, "--- %s %s ---\ntranscript: %s\n%s\n\n", label, time.Now().Format(time.RFC3339), transcript, raw) //nolint:errcheck
 39 }
 40 
 41 // GameModel adapts an llm.Client to game.GameModel. It sends the reconstructed
 42diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
 43index dc981bfc1b6a8154154712280fc67f57d075866f..e05c606d1975019c949506e401849a043239f2e9 100644
 44--- a/internal/game/orchestrator.go
 45+++ b/internal/game/orchestrator.go
 46@@ -221,6 +221,7 @@ func displayFromReply(action string, r llm.GameReply, j llm.JudgeResult, hasJudg
 47 	e := DisplayEntry{Action: action, Desc: r.Desc, Romaji: r.Romaji, English: r.English, HasSpeech: hasSpeech(r)}
 48 	if hasJudge {
 49 		e.HasJudge = true
 50+		e.PlayerRomaji = j.Romaji
 51 		e.Score = j.Score
 52 		e.Feedback = j.Feedback
 53 	}
 54diff --git a/internal/game/state.go b/internal/game/state.go
 55index db0875d55f0c8617d16b593a1bec5f4b21ea314e..338bf49e306e7c0bda457d3b754bdc4d9d10ec53 100644
 56--- a/internal/game/state.go
 57+++ b/internal/game/state.go
 58@@ -37,16 +37,17 @@ type Segment struct {
 59 // are empty and HasSpeech false for pure narration turns. ShowRomaji and
 60 // ShowEnglish are independent: either can be revealed in either order.
 61 type DisplayEntry struct {
 62-	Action      string
 63-	Desc        string
 64-	Romaji      string
 65-	English     string
 66-	HasSpeech   bool
 67-	Score       int
 68-	Feedback    string
 69-	HasJudge    bool
 70-	ShowRomaji  bool
 71-	ShowEnglish bool
 72+	Action       string
 73+	Desc         string
 74+	Romaji       string
 75+	English      string
 76+	HasSpeech    bool
 77+	PlayerRomaji string
 78+	Score        int
 79+	Feedback     string
 80+	HasJudge     bool
 81+	ShowRomaji   bool
 82+	ShowEnglish  bool
 83 }
 84 
 85 // State is the pure, in-memory session. The LLM owns world consistency; State
 86diff --git a/internal/llm/contract.go b/internal/llm/contract.go
 87index 0e5c1a4ee6f96c0bb80b3bd7c7c8930b9c442516..6d4007a18388946bd2973fbfb10d0423443fbb8a 100644
 88--- a/internal/llm/contract.go
 89+++ b/internal/llm/contract.go
 90@@ -47,7 +47,6 @@ func kindString(k ContractErrorKind) string {
 91 }
 92 
 93 var (
 94-	npcFieldNames   = []string{"ROMAJI", "KANA", "ENGLISH"}
 95 	judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
 96 	gameFieldNames  = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
 97 )
 98@@ -96,26 +95,6 @@ func requireFields(vals map[string]string, names []string) error {
 99 	return nil
100 }
101 
102-type NPCReply struct {
103-	Romaji  string
104-	Kana    string
105-	English string
106-}
107-
108-// ParseNPCReply parses the three-field NPC reply contract. It tolerates
109-// surrounding whitespace and rejects missing or duplicate fields with a
110-// recoverable *ContractError rather than panicking.
111-func ParseNPCReply(raw string) (NPCReply, error) {
112-	vals, err := parseFields(raw, npcFieldNames)
113-	if err != nil {
114-		return NPCReply{}, err
115-	}
116-	if err := requireFields(vals, npcFieldNames); err != nil {
117-		return NPCReply{}, err
118-	}
119-	return NPCReply{Romaji: vals["ROMAJI"], Kana: vals["KANA"], English: vals["ENGLISH"]}, nil
120-}
121-
122 type JudgeResult struct {
123 	Score    int
124 	Romaji   string
125@@ -144,7 +123,7 @@ func ParseJudge(raw string) (JudgeResult, error) {
126 
127 type GameReply struct {
128 	Location string // stable id of the player's current place, always present
129-	Talk     string // name of the person being spoken to, or "none"; always present
130+	Talk     string // who you are speaking to (role or appearance), or "none"; always present
131 	Desc     string // English narration; may be empty
132 	Romaji   string // NPC speech in romaji; part of the spoken triple
133 	Kana     string // same sentence in kana; part of the spoken triple
134diff --git a/internal/llm/history.go b/internal/llm/history.go
135index 40829d0490e833133785ec2c12ac526a0d6df888..86579b127e68f1968b93620393151c71520b2506 100644
136--- a/internal/llm/history.go
137+++ b/internal/llm/history.go
138@@ -1,57 +1,18 @@
139 package llm
140 
141-import "unicode/utf8"
142-
143-// Turn is one exchange: the player's spoken line and the NPC's reply to it.
144-type Turn struct {
145-	User      string
146-	Assistant string
147-}
148-
149 const (
150 	maxContextTokens      = 16384
151 	charsPerTokenEstimate = 1
152-	defaultMaxTurns       = 8
153 )
154 
155-// HistoryPolicy bounds how much prior conversation a request may carry. The
156-// char budget is a rough estimate of the whole prompt, derived from the model
157-// context at charsPerTokenEstimate (one token per rune keeps the estimate on
158-// the safe side for kana/kanji and leaves romaji far inside the limit).
159+// HistoryPolicy bounds how much prior conversation a request may carry. The char
160+// budget is a rough estimate of the whole prompt, derived from the model context
161+// at charsPerTokenEstimate (one token per rune keeps the estimate on the safe side
162+// for kana/kanji and leaves romaji far inside the limit).
163 type HistoryPolicy struct {
164-	MaxTurns         int
165 	PromptCharBudget int
166 }
167 
168 func DefaultHistoryPolicy() HistoryPolicy {
169-	return HistoryPolicy{MaxTurns: defaultMaxTurns, PromptCharBudget: maxContextTokens * charsPerTokenEstimate}
170-}
171-
172-func runeCount(s string) int { return utf8.RuneCountInString(s) }
173-
174-// BoundHistory returns the newest turns of prior that fit within maxTurns turns
175-// and whose combined character count stays within charBudget. Turns keep their
176-// original order; when the budget is exceeded the oldest turns are dropped
177-// first, so the result honors whichever cap (turns or chars) is tighter.
178-func BoundHistory(prior []Turn, maxTurns int, charBudget int) []Turn {
179-	if maxTurns <= 0 || charBudget <= 0 || len(prior) == 0 {
180-		return nil
181-	}
182-	start := 0
183-	if n := len(prior); n > maxTurns {
184-		start = n - maxTurns
185-	}
186-	kept := prior[start:]
187-	used := 0
188-	for _, t := range kept {
189-		used += runeCount(t.User) + runeCount(t.Assistant)
190-	}
191-	for len(kept) > 0 && used > charBudget {
192-		used -= runeCount(kept[0].User) + runeCount(kept[0].Assistant)
193-		kept = kept[1:]
194-	}
195-	if len(kept) == 0 {
196-		return nil
197-	}
198-	return kept
199+	return HistoryPolicy{PromptCharBudget: maxContextTokens * charsPerTokenEstimate}
200 }
201diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
202index 9233815ff846a83a09612a6bc8e0c16454ee4457..50d7eac2d6f1f57d4fd2949f672e72aefa1b7f27 100644
203--- a/internal/llm/prompt.go
204+++ b/internal/llm/prompt.go
205@@ -5,25 +5,6 @@ import (
206 	"strings"
207 )
208 
209-// npcSystemTemplate wraps the authored persona description and fixes the
210-// three-field reply contract. It never names a game, quest, or scenario,
211-// never refers to grading, and tells the model to speak as a normal person
212-// rather than a teacher.
213-const npcSystemTemplate = `You are %s.
214-
215-Speak naturally, as yourself, in Japanese. Keep your reply short and conversational. Do not teach language, correct mistakes, or mention any evaluation.
216-
217-Your reply must be exactly three lines of plain text, in this order. No extra lines, no markdown, no code fences. Every line starts with its field key, then a pipe character |, then the value. Never omit, rename, or reorder the keys.
218-
219-ROMAJI|<romaji of the sentence you say>
220-KANA|<the same sentence written in kana>
221-ENGLISH|<a natural English translation of that sentence>
222-
223-Example reply:
224-ROMAJI|nee san, chuumon wa?
225-KANA|ねえさん、注文は?
226-ENGLISH|Miss, what is your order?`
227-
228 // judgeSystemTemplate is persona-free and fixes the three-field judge contract.
229 // It scores fluency and naturalness, accepting any natural phrasing, and
230 // transcribes the given line into Hepburn romaji. It never names a game, quest,
231@@ -43,37 +24,9 @@ SCORE|85
232 ROMAJI|konnichiwa
233 FEEDBACK|It's a polite greeting, but you can use a more specific greeting for the time period e.g. "ohayoo gozaimasu"`
234 
235-// NPCSystemPrompt renders the NPC system message for one location from its
236-// authored description. Every NPC request for that location starts with this
237-// exact text.
238-func NPCSystemPrompt(description string) string {
239-	return fmt.Sprintf(npcSystemTemplate, fallback(description, "a friendly local"))
240-}
241-
242 // JudgeSystemPrompt returns the static judge system message.
243 func JudgeSystemPrompt() string { return judgeSystemTemplate }
244 
245-// BuildNPCMessages assembles the NPC request: a system message holding the
246-// stable persona description, bounded prior history, and the current raw
247-// transcript. Judge output never enters this prompt.
248-func BuildNPCMessages(persona, transcript string, prior []Turn, policy HistoryPolicy) []Message {
249-	system := NPCSystemPrompt(persona)
250-	historyBudget := policy.PromptCharBudget - runeCount(system) - runeCount(transcript)
251-	if historyBudget < 0 {
252-		historyBudget = 0
253-	}
254-	turns := BoundHistory(prior, policy.MaxTurns, historyBudget)
255-
256-	msgs := make([]Message, 0, len(turns)*2+2)
257-	msgs = append(msgs, Message{Role: RoleSystem, Content: system})
258-	for _, t := range turns {
259-		msgs = append(msgs, Message{Role: RoleUser, Content: t.User})
260-		msgs = append(msgs, Message{Role: RoleAssistant, Content: t.Assistant})
261-	}
262-	msgs = append(msgs, Message{Role: RoleUser, Content: transcript})
263-	return msgs
264-}
265-
266 func fallback(value, def string) string {
267 	if strings.TrimSpace(value) == "" {
268 		return def
269@@ -98,8 +51,13 @@ Established facts about this world:
270 %s
271 
272 How to respond
273-- Treat the player's input as their next move and advance the scene from it.
274-- You may introduce new places and people as the player explores, but stay strictly consistent with the established facts and anything earlier in this conversation (names, roles, prices, directions).
275+- The player's typed English text is an instruction to you (the world). It is never speech spoken aloud; no NPC hears it. Advance the scene from it.
276+- Actions like "wait", "sit", "look around" advance the scene. For waiting or pausing, fast-forward: skip the passing time and narrate what happens when the next event occurs.
277+- Questions in English are meta-questions about the world (e.g. "when will my food arrive?"). Answer them in DESC as narration. Do not make an NPC answer a question the player only typed.
278+- A physical action directed at a person ("bow to the cook", "point at the menu") is a gesture: the person may react with a small action or brief word based on context, but this is their choice, not a conversation.
279diff --git a/internal/ui/app.go b/internal/ui/app.go
280index b8ea853342f0d7343aef2c2ef1a191ed9e00b14a..068beddee61d0ba47879f46424d96671ab9642ef 100644
281--- a/internal/ui/app.go
282+++ b/internal/ui/app.go
283@@ -6,6 +6,7 @@ import (
284 	"time"
285 
286 	"github.com/charmbracelet/bubbletea"
287+	"github.com/charmbracelet/lipgloss"
288 
289 	"japanese/internal/game"
290 )
291@@ -17,6 +18,14 @@ const tickInterval = 200 * time.Millisecond
292 // shows where the player begins before any typed input.
293 const openingAction = "Look around."
294 
295+var (
296+	actionStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("38"))  // cyan
297+	narrationStyle  = lipgloss.NewStyle()                                   // default
298+	playerSaidStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))  // green
299+	judgeStyle      = lipgloss.NewStyle().Foreground(lipgloss.Color("220")) // yellow
300+	hintStyle       = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // gray
301+)
302+
303 type phase int
304 
305 const (
306@@ -205,25 +214,28 @@ func (m *model) phaseWord() string {
307 func entryLines(e game.DisplayEntry) []string {
308 	var lines []string
309 	if e.Action != "" {
310-		lines = append(lines, "> "+sanitizeRomajiOnly(e.Action))
311+		lines = append(lines, actionStyle.Render("> "+sanitizeRomajiOnly(e.Action)))
312 	}
313 	if e.Desc != "" {
314-		lines = append(lines, sanitizeRomajiOnly(e.Desc))
315+		lines = append(lines, narrationStyle.Render(sanitizeRomajiOnly(e.Desc)))
316 	}
317 	if e.HasSpeech {
318 		if e.ShowRomaji {
319 			lines = append(lines, "  » "+sanitizeRomajiOnly(valueOrNone(e.Romaji)))
320 		} else {
321-			lines = append(lines, "  » [F3] romaji hidden")
322+			lines = append(lines, hintStyle.Render("  » [F3] romaji hidden"))
323 		}
324 		if e.ShowEnglish {
325 			lines = append(lines, "    "+sanitizeRomajiOnly(valueOrNone(e.English)))
326 		} else {
327-			lines = append(lines, "    [F4] english hidden")
328+			lines = append(lines, hintStyle.Render("    [F4] english hidden"))
329 		}
330 	}
331 	if e.HasJudge {
332-		lines = append(lines, fmt.Sprintf("  judge %d/100 — %s", e.Score, sanitizeRomajiOnly(valueOrNone(e.Feedback))))
333+		if e.PlayerRomaji != "" {
334+			lines = append(lines, playerSaidStyle.Render("  said: "+sanitizeRomajiOnly(e.PlayerRomaji)))
335+		}
336+		lines = append(lines, judgeStyle.Render(fmt.Sprintf("  judge %d/100 — %s", e.Score, sanitizeRomajiOnly(valueOrNone(e.Feedback)))))
337 	}
338 	return lines
339 }
340diff --git a/internal/ui/render.go b/internal/ui/render.go
341index a16e1f20cfef614587c6c0513f4ab2d42a9df01d..4862ac3d5109d7d2ac4367955bf91adb3772e6ee 100644
342--- a/internal/ui/render.go
343+++ b/internal/ui/render.go
344@@ -54,9 +54,9 @@ func statusLine(status, phaseWord string, recElapsed, recCap int) string {
345 		return "thinking…"
346 	}
347 	if status != "" {
348-		return sanitizeRomajiOnly(status) + "    " + controlsHint
349+		return sanitizeRomajiOnly(status) + "    " + hintStyle.Render(controlsHint)
350 	}
351-	return controlsHint
352+	return hintStyle.Render(controlsHint)
353 }
354 
355 func valueOrNone(s string) string {