state.go
11533 bytes
1package game
2
3import (
4 "fmt"
5 "strings"
6 "unicode/utf8"
7
8 "git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/llm"
9)
10
11// Message is a role/content pair owned by the game layer so session and display
12// code can carry turns without importing llm into render paths. Role is a plain
13// string: "user" or "assistant".
14type Message struct {
15 Role string
16 Content string
17}
18
19const (
20 roleUser = "user"
21 roleAssistant = "assistant"
22)
23
24// Segment is one continuous stretch of the session at a single location. While
25// open it holds raw Messages; once closed, Messages are dropped and Summary
26// stands in for them in every future prompt. Prefix summarizes any oldest
27// messages compacted out of an over-long open segment before it closes.
28type Segment struct {
29 Location string
30 Prefix string
31 Messages []Message
32 Summary string
33}
34
35// DisplayEntry is one turn as shown in the adventure log. Action holds the
36// player's typed action text; it stays empty for spoken turns. Speech fields
37// are empty and HasSpeech false for pure narration turns. ShowRomaji and
38// ShowEnglish are independent: either can be revealed in either order.
39type DisplayEntry struct {
40 Action string
41 Desc string
42 Romaji string
43 English string
44 HasSpeech bool
45 PlayerRomaji string
46 Score int
47 Feedback string
48 HasJudge bool
49 ShowRomaji bool
50 ShowEnglish bool
51 HasAsk bool
52 AskRomaji string
53 AskTranslation string
54 AskBreakdown string
55}
56
57// sheetEntry is one stored character sheet, kept in first-met order so injected
58// lines stay stable across turns (KV cache prefix). location and talk are
59// normalized (trimmed + lowercased); sheet is the parsed profile.
60type sheetEntry struct {
61 location string
62 talk string
63 sheet llm.Sheet
64}
65
66// State is the pure, in-memory session. The LLM owns world consistency; State
67// owns only segmentation (location boundaries), conversation (last TALK), the
68// over-long-segment compaction bookkeeping, and the display log. No I/O here.
69type State struct {
70 brief string
71 talk string
72 closed []Segment
73 open Segment
74 display []DisplayEntry
75 sheets []sheetEntry
76}
77
78// NewState builds a session from the scenario brief. The open segment is
79// empty until the first reply sets its location.
80func NewState(brief string) *State {
81 return &State{brief: brief, talk: "none"}
82}
83
84// Brief returns the scenario brief (for building the system prompt/warmup).
85func (s *State) Brief() string { return s.brief }
86
87// Location returns the player's current location id ("" before the first reply).
88func (s *State) Location() string { return s.open.Location }
89
90// Talk returns the last TALK value, normalized so "none" means no conversation.
91func (s *State) Talk() string {
92 if s.talk == "" {
93 return "none"
94 }
95 return s.talk
96}
97
98// RecordTyped appends one silent typed action, tagged so the model can never
99// mistake it for speech. Call it before Prompt for that turn; the matching
100// assistant reply is added by ApplyReply.
101func (s *State) RecordTyped(action string) {
102 s.appendUser("[action] " + action)
103}
104
105// RecordSpoken appends one spoken line - the only player words NPCs can hear.
106func (s *State) RecordSpoken(line string) {
107 s.appendUser("[spoken] " + line)
108}
109
110// RecordUser appends one raw, untagged user message to the open segment
111// (internal world notes, not player input).
112func (s *State) RecordUser(content string) {
113 s.appendUser(content)
114}
115
116func (s *State) appendUser(content string) {
117 s.open.Messages = append(s.open.Messages, Message{Role: roleUser, Content: content})
118}
119
120// OpenMessages returns a copy of the open segment's raw messages.
121func (s *State) OpenMessages() []Message {
122 out := make([]Message, len(s.open.Messages))
123 copy(out, s.open.Messages)
124 return out
125}
126
127// OpenCharCount is the rune count of the open segment's raw messages.
128func (s *State) OpenCharCount() int {
129 n := 0
130 for _, m := range s.open.Messages {
131 n += utf8.RuneCountInString(m.Content)
132 }
133 return n
134}
135
136// ApplyOpenPrefix stores a summary of the oldest compacted messages as the open
137// segment's Prefix and drops those first cut messages from the raw log. The kept
138// suffix must begin with an assistant message (the caller guarantees it).
139func (s *State) ApplyOpenPrefix(prefix string, cut int) {
140 s.open.Prefix = prefix
141 if cut > 0 && cut <= len(s.open.Messages) {
142 rest := make([]Message, 0, len(s.open.Messages)-cut)
143 rest = append(rest, s.open.Messages[cut:]...)
144 s.open.Messages = rest
145 }
146}
147
148// ApplyReply records an assistant game reply and performs segmentation. It
149// returns whether the reply moved the player to a new location; on a move it also
150// returns the closed segment's location, prefix, and raw messages so the caller
151// can compact them. On a move the triggering user message stays in the old
152// segment and the new open segment starts with this reply's assistant message.
153// talk is always updated from the reply.
154func (s *State) ApplyReply(reply llm.GameReply) (moved bool, closedLoc string, closedPrefix string, closedMsgs []Message) {
155 loc := normalizeLocation(reply.Location)
156 s.talk = normalizeTalk(reply.Talk)
157 assistant := Message{Role: roleAssistant, Content: replyText(reply)}
158
159 if s.open.Location == "" || loc == s.open.Location {
160 if s.open.Location == "" {
161 s.open.Location = loc
162 }
163 s.open.Messages = append(s.open.Messages, assistant)
164 return false, "", "", nil
165 }
166
167 closedLoc = s.open.Location
168 closedPrefix = s.open.Prefix
169 closedMsgs = s.open.Messages
170 s.open = Segment{Location: loc, Messages: []Message{assistant}}
171 return true, closedLoc, closedPrefix, closedMsgs
172}
173
174// RegisterSummary records a compacted closed segment in chronological order.
175func (s *State) RegisterSummary(location, summary string) {
176 s.closed = append(s.closed, Segment{Location: location, Summary: summary})
177}
178
179// ClosedCount returns how many closed segments are stored.
180func (s *State) ClosedCount() int { return len(s.closed) }
181
182// DropOldestSummary removes the oldest closed segment's recap to free context
183// budget for more recent memory. No-op when there are no closed segments.
184func (s *State) DropOldestSummary() {
185 if len(s.closed) > 0 {
186 s.closed = s.closed[1:]
187 }
188}
189
190// RegisterSheet stores one character sheet in first-met order. The key is the
191// normalized location + talk pair; if that pair already has a sheet this is a
192// no-op (the first sheet wins as the consistency anchor).
193func (s *State) RegisterSheet(location, talk string, sheet llm.Sheet) {
194 loc := normalizeLocation(location)
195 tk := normalizeLocation(talk)
196 for _, e := range s.sheets {
197 if e.location == loc && e.talk == tk {
198 return
199 }
200 }
201 s.sheets = append(s.sheets, sheetEntry{location: loc, talk: tk, sheet: sheet})
202}
203
204// SheetFor returns the stored sheet for a location + talk pair and whether it
205// exists. Both inputs are normalized before lookup.
206func (s *State) SheetFor(location, talk string) (llm.Sheet, bool) {
207 loc := normalizeLocation(location)
208 tk := normalizeLocation(talk)
209 for _, e := range s.sheets {
210 if e.location == loc && e.talk == tk {
211 return e.sheet, true
212 }
213 }
214 return llm.Sheet{}, false
215}
216
217// sheetsAt returns the stored sheets for one normalized location, in order.
218func (s *State) sheetsAt(location string) []sheetEntry {
219 var out []sheetEntry
220 for _, e := range s.sheets {
221 if e.location == location {
222 out = append(out, e)
223 }
224 }
225 return out
226}
227
228// promptContext assembles the context block (closed recaps + current location's
229// sheets + open prefix) and the open messages to send after it. Any leading user
230// messages are dropped when a context block is present, since they would otherwise
231// follow a user-role context line.
232func (s *State) promptContext() (string, []Message) {
233 var ctx strings.Builder
234 for _, seg := range s.closed {
235 if seg.Summary != "" {
236 fmt.Fprintf(&ctx, "Recap (%s): %s\n", seg.Location, seg.Summary)
237 }
238 }
239 if sheets := s.sheetsAt(s.open.Location); len(sheets) > 0 {
240 ctx.WriteString("Character sheets (internal; never quote or describe these):\n")
241 for _, e := range sheets {
242 fmt.Fprintf(&ctx, "- %s:%s %s\n", e.location, e.talk, e.sheet.Text())
243 }
244 }
245 if s.open.Prefix != "" {
246 fmt.Fprintf(&ctx, "Earlier at this location: %s\n", s.open.Prefix)
247 }
248
249 openMsgs := s.open.Messages
250 if ctx.Len() > 0 {
251 for len(openMsgs) > 0 && openMsgs[0].Role == roleUser {
252 openMsgs = openMsgs[1:]
253 }
254 }
255 return strings.TrimRight(ctx.String(), "\n"), openMsgs
256}
257
258// Prompt reconstructs the full game-loop message list for the next turn: the
259// system prompt, then (only when prior context exists) one user "context" message
260// holding all closed-segment recaps plus the open segment's prefix, then the open
261// segment's raw messages. Pure; it does not mutate state.
262func (s *State) Prompt() []llm.Message {
263 msgs := []llm.Message{{Role: llm.RoleSystem, Content: llm.GameSystemPrompt(s.brief)}}
264 ctxBlock, openMsgs := s.promptContext()
265 if ctxBlock != "" {
266 msgs = append(msgs, llm.Message{Role: llm.RoleUser, Content: ctxBlock})
267 }
268 for _, m := range openMsgs {
269 msgs = append(msgs, llm.Message{Role: roleOf(m.Role), Content: m.Content})
270 }
271 return msgs
272}
273
274// PromptCharCount returns the rune count of the full prompt that Prompt() would
275// send (system + context block + open messages). Used to bound total context.
276func (s *State) PromptCharCount() int {
277 n := utf8.RuneCountInString(llm.GameSystemPrompt(s.brief))
278 ctxBlock, openMsgs := s.promptContext()
279 n += utf8.RuneCountInString(ctxBlock)
280 for _, m := range openMsgs {
281 n += utf8.RuneCountInString(m.Content)
282 }
283 return n
284}
285
286// RecordDisplay appends one turn to the display log.
287func (s *State) RecordDisplay(e DisplayEntry) { s.display = append(s.display, e) }
288
289// Display returns a copy of the display log for rendering.
290func (s *State) Display() []DisplayEntry {
291 out := make([]DisplayEntry, len(s.display))
292 copy(out, s.display)
293 return out
294}
295
296// RevealRomaji marks romaji shown on the most recent speech entry. It returns
297// whether anything changed.
298func (s *State) RevealRomaji() bool {
299 i := latestSpeechIndex(s.display)
300 if i < 0 || s.display[i].ShowRomaji {
301 return false
302 }
303 s.display[i].ShowRomaji = true
304 return true
305}
306
307// RevealEnglish marks English shown on the most recent speech entry. It returns
308// whether anything changed.
309func (s *State) RevealEnglish() bool {
310 i := latestSpeechIndex(s.display)
311 if i < 0 || s.display[i].ShowEnglish {
312 return false
313 }
314 s.display[i].ShowEnglish = true
315 return true
316}
317
318func latestSpeechIndex(d []DisplayEntry) int {
319 for i := len(d) - 1; i >= 0; i-- {
320 if d[i].HasSpeech {
321 return i
322 }
323 }
324 return -1
325}
326
327func roleOf(role string) llm.Role {
328 if role == roleAssistant {
329 return llm.RoleAssistant
330 }
331 return llm.RoleUser
332}
333
334// replyText renders a parsed game reply back into the canonical contract text so
335// the session log carries exactly what the model produced. The spoken triple is
336// emitted only when present (any non-empty field).
337func replyText(r llm.GameReply) string {
338 var b strings.Builder
339 fmt.Fprintf(&b, "LOCATION|%s\nTALK|%s\nDESC|%s", r.Location, r.Talk, r.Desc)
340 if hasSpeech(r) {
341 fmt.Fprintf(&b, "\nROMAJI|%s\nKANA|%s\nENGLISH|%s", r.Romaji, r.Kana, r.English)
342 }
343 return b.String()
344}
345
346func hasSpeech(r llm.GameReply) bool {
347 return r.Romaji != "" || r.Kana != "" || r.English != ""
348}
349
350func normalizeLocation(id string) string { return strings.ToLower(strings.TrimSpace(id)) }
351
352func normalizeTalk(t string) string {
353 t = strings.TrimSpace(t)
354 if t == "" || strings.EqualFold(t, "none") {
355 return "none"
356 }
357 return t
358}