Parent directory

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}