a5407cc4be035bc663b5f36aa1340ac922dfa5bb

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

Message

Add brief-only scenario, slot pinning, and character sheets

- Scenario (D1): replace the city.json seed with a plain-text small_city.md
  brief; loader reads text; rename seed to brief across state, prompt, main,
  and orchestrator; drop the fallback default.
- Slot pinning (D5): id_slot in the chat request body, Client.Slot, separate
  game/scratch clients with split warmup, --llm-game-slot/--llm-scratch-slot
  flags, docs.
- Character sheets (D2/D3/D6): Sheet contract with ParseSheet and Text(), sheet
  system prompt plus BuildSheetMessages/SheetSystemPrompt, ordered State storage
  (RegisterSheet/SheetFor) with cache-stable injection in Prompt(), and the New
  characters rules in the game prompt.
- AGENTS.md: note that workers commit their batch when done.

Diff

This diff is truncated to protect this page.

  1diff --git a/AGENTS.md b/AGENTS.md
  2index 10e6bd5c98ac4e7776ffadfd0e835c2d34d8a181..cee212c23d6bfd87b65fdcbf16acf73c73156c4b 100644
  3--- a/AGENTS.md
  4+++ b/AGENTS.md
  5@@ -54,6 +54,8 @@ almost fully allocated to the chat model.
  6   constraints, how to verify), not a full file to transcribe. The main context
  7   does the design and thinking; the worker turns that into code. Pasting whole
  8   files wastes context and defeats the purpose of offloading work.
  9+- Workers commit their changes when done: one commit per batch with a short,
 10+  polished message describing the change.
 11 
 12 ## Development
 13 
 14diff --git a/README.md b/README.md
 15index e0eee2b72b7b76d3951d0ce511966c27b8a3592c..02364a5c1af0c25464ce046909cc817b6b0b0e67 100644
 16--- a/README.md
 17+++ b/README.md
 18@@ -44,6 +44,8 @@ local setup.
 19 | Service           | Flag               | Env var           | Default                                   | Use                                             |
 20 | ----------------- | ------------------ | ----------------- | ----------------------------------------- | ----------------------------------------------- |
 21 | LLM router URL    | `--llm-url`        | `JP_LLM_BASE_URL` | `https://llama.home.theedgeofrage.com`    | `POST /v1/chat/completions`                     |
 22+| LLM game slot     | `--llm-game-slot`  | `JP_LLM_GAME_SLOT`    | `0`   | llama-server slot for game-loop requests (`-1` = server decides) |
 23+| LLM scratch slot  | `--llm-scratch-slot` | `JP_LLM_SCRATCH_SLOT` | `1`   | llama-server slot for judge and compaction requests (`-1` = server decides) |
 24 | TTS base URL      | `--tts-url`        | `JP_TTS_BASE_URL` | `http://127.0.0.1:8080/v1`                | `POST /audio/speech`                            |
 25 | STT server URL    | `--stt-url`        | `JP_STT_URL`      | `http://127.0.0.1:8178`                  | multipart `POST /inference`                     |
 26 | STT language      | `--stt-language`   | `JP_STT_LANGUAGE` | `ja`                                      | Whisper request language (ISO 639-1)           |
 27diff --git a/assets/scenarios/city.json b/assets/scenarios/city.json
 28deleted file mode 100644
 29index 42ab1491e9ead66edeed40f2ad790e8d96a91bf5..0000000000000000000000000000000000000000
 30--- a/assets/scenarios/city.json
 31+++ /dev/null
 32@@ -1,4 +0,0 @@
 33-{
 34-  "name": "city",
 35diff --git a/assets/scenarios/small_city.md b/assets/scenarios/small_city.md
 36new file mode 100644
 37index 0000000000000000000000000000000000000000..0a10147040adce89ffe248259dd010df91550b59
 38--- /dev/null
 39+++ b/assets/scenarios/small_city.md
 40@@ -0,0 +1 @@
 41+A small Japanese city in the evening. The player is a visitor exploring on foot.
 42diff --git a/cmd/jp/main.go b/cmd/jp/main.go
 43index d36d64b98b2ef178bd5a0d455017a5785c9d7811..0da8cfa1ad958185d0d5b02001dc707aeb1662b6 100644
 44--- a/cmd/jp/main.go
 45+++ b/cmd/jp/main.go
 46@@ -35,10 +35,13 @@ func main() {
 47 	}
 48 
 49 	hc := &http.Client{Timeout: requestTimeout}
 50-	llmClient := llm.NewClient(cfg, hc)
 51+	gameClient := llm.NewClient(cfg, hc)
 52+	gameClient.Slot = cfg.LLMConfig.GameSlot
 53+	scratchClient := llm.NewClient(cfg, hc)
 54+	scratchClient.Slot = cfg.LLMConfig.ScratchSlot
 55 
 56 	llmUp := true
 57-	if err := llmClient.LoadModel(); err != nil {
 58+	if err := gameClient.LoadModel(); err != nil {
 59 		fmt.Fprintf(os.Stderr, "jp: LLM unavailable: %v\n", err)
 60 		llmUp = false
 61 	}
 62@@ -51,15 +54,18 @@ func main() {
 63 
 64 	if llmUp {
 65 		ctx, cancel := context.WithTimeout(context.Background(), warmupTimeout)
 66-		err := llmClient.Warmup(ctx, warmupSystems(sc.Seed))
 67+		err := gameClient.Warmup(ctx, []string{llm.GameSystemPrompt(sc.Brief)})
 68+		if err == nil {
 69+			err = scratchClient.Warmup(ctx, []string{llm.JudgeSystemPrompt(), llm.CompactionPrompt()})
 70+		}
 71 		cancel()
 72 		if err != nil {
 73 			fmt.Fprintf(os.Stderr, "jp: LLM warmup failed: %v\n", err)
 74 		}
 75 	}
 76 
 77-	state := game.NewState(sc.Seed)
 78-	orch := buildOrchestrator(cfg, state, hc, llmClient)
 79+	state := game.NewState(sc.Brief)
 80+	orch := buildOrchestrator(cfg, state, hc, gameClient, scratchClient)
 81 
 82 	m := ui.NewModel(state, orch, int(recordCap.Seconds()))
 83 	if _, err := tea.NewProgram(m).Run(); err != nil {
 84@@ -67,10 +73,10 @@ func main() {
 85 	}
 86 }
 87 
 88-func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, llmClient *llm.Client) *game.Orchestrator {
 89-	gameModel := &adapters.GameModel{Client: llmClient}
 90-	compactor := &adapters.Compactor{Client: llmClient}
 91-	judgeModel := &adapters.JudgeModel{Client: llmClient}
 92+func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, gameClient, scratchClient *llm.Client) *game.Orchestrator {
 93+	gameModel := &adapters.GameModel{Client: gameClient}
 94+	compactor := &adapters.Compactor{Client: scratchClient}
 95+	judgeModel := &adapters.JudgeModel{Client: scratchClient}
 96 
 97 	speechIn := &adapters.SpeechInput{
 98 		Recorder: stt.NewRecorder(cfg.RecordCommand, recordCap),
 99@@ -84,17 +90,6 @@ func buildOrchestrator(cfg *config.Config, state *game.State, hc *http.Client, l
100 	return game.NewOrchestrator(state, gameModel, judgeModel, compactor, speechIn, speechOut)
101 }
102 
103-// warmupSystems lists the three static system prompts the game will send: this
104-// scenario's game-loop prompt, the judge, and the compaction prompt. Warming
105-// them pins each prefix in the server's KV cache.
106-func warmupSystems(seed string) []string {
107-	return []string{
108-		llm.GameSystemPrompt(seed),
109-		llm.JudgeSystemPrompt(),
110-		llm.CompactionPrompt(),
111-	}
112-}
113-
114 func fatalf(format string, args ...any) {
115 	fmt.Fprintf(os.Stderr, "jp: "+format+"\n", args...)
116 	os.Exit(1)
117diff --git a/docs/services.md b/docs/services.md
118index e5b902bbb0fb74884346e4695c63c0c207dfb29b..3c7d4ae798af5aa0b7aede4fabe3cb073e4643a0 100644
119--- a/docs/services.md
120+++ b/docs/services.md
121@@ -36,8 +36,29 @@ Model: `unsloth/Qwen3-8B-GGUF:UD-Q4_K_XL`, resolvable by the router as `jp`. The
122 sends OpenAI-compatible requests with `model` set to `jp`, disables Qwen3
123 thinking on every request (`chat_template_kwargs.enable_thinking: false`),
124 sets `cache_prompt: true` so the server keeps prompt prefixes cached between
125-turns, and reads streaming SSE. The model chat template (`--jinja`) is used;
126-ChatML is not built manually by the client.
127+turns, pins each request to a llama-server slot via `id_slot` (see below), and
128+reads streaming SSE. The model chat template (`--jinja`) is used; ChatML is
129+not built manually by the client.
130+
131+### Slot pinning
132+
133+Each request body carries an `id_slot` field that assigns the task to a
134+specific llama-server slot, bypassing the server's LRU/similarity
135+auto-selection. The game uses two slots so scratch requests can never evict
136+the game loop's cached context:
137+
138+| Client use                                   | Flag                   | Env var               | Default |
139+| -------------------------------------------- | ---------------------- | --------------------- | ------- |
140+| Game loop (world replies)                    | `--llm-game-slot`      | `JP_LLM_GAME_SLOT`    | `0`     |
141+| Scratch (judge, compaction; warmup included) | `--llm-scratch-slot`   | `JP_LLM_SCRATCH_SLOT` | `1`     |
142+
143+Warmup follows the same split: the game system prompt warms on the game
144+client, the judge and compaction prompts warm on the scratch client, so initial
145+cache placement is deterministic per slot.
146+
147+**Fallback:** if the router does not honor `id_slot`, set both env vars to `-1`
148+(`JP_LLM_GAME_SLOT=-1 JP_LLM_SCRATCH_SLOT=-1`). The server then auto-selects a
149+slot by prompt similarity (`-sps`, default 0.10) and falls back to LRU.
150 
151 At startup, before anything else loads, the game preloads the model with
152 `POST {JP_LLM_BASE_URL}/models/load` and body `{"model": "jp"}` (30s timeout).
153diff --git a/internal/config/config.go b/internal/config/config.go
154index 58876d5762901141cf1e186846e1f080e2eec84c..09d040f2f8774ce4665db4729b15430c85bdc054 100644
155--- a/internal/config/config.go
156+++ b/internal/config/config.go
157@@ -17,6 +17,8 @@ type LLMConfig struct {
158 	Temperature    float64 `long:"temperature" env:"TEMPERATURE" default:"1.0"`
159 	MaxTokens      int     `long:"max-tokens" env:"MAX_TOKENS" default:"256"`
160 	EnableThinking bool    `long:"enable-thinking" env:"ENABLE_THINKING"`
161+	GameSlot       int     `long:"llm-game-slot" env:"JP_LLM_GAME_SLOT" default:"0" description:"llama-server slot for game-loop requests (-1 = server decides)"`
162+	ScratchSlot    int     `long:"llm-scratch-slot" env:"JP_LLM_SCRATCH_SLOT" default:"1" description:"llama-server slot for judge and compaction requests (-1 = server decides)"`
163 }
164 
165 // Config is the shared option group. Parsed with github.com/jessevdk/go-flags.
166@@ -28,7 +30,7 @@ type Config struct {
167 	STTLanguage   string `long:"stt-language" env:"JP_STT_LANGUAGE" default:"ja" description:"Whisper request language (ISO 639-1 code)"`
168 	RecordCommand string `long:"record-command" default:"arecord" description:"Recorder command for 16kHz mono S16_LE WAV capture"`
169 
170-	Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/city.json" description:"path to the scenario seed JSON"`
171+	Scenario string `long:"scenario" env:"JP_SCENARIO_PATH" default:"assets/scenarios/small_city.md" description:"path to the scenario brief file"`
172 }
173 
174 func ParseConfig() *Config {
175diff --git a/internal/game/orchestrator.go b/internal/game/orchestrator.go
176index e05c606d1975019c949506e401849a043239f2e9..1b0696a44dd87c49e6e645985fee000ec9bec517 100644
177--- a/internal/game/orchestrator.go
178+++ b/internal/game/orchestrator.go
179@@ -78,7 +78,7 @@ func NewOrchestrator(state *State, g GameModel, j JudgeModel, c Compactor, in Sp
180 		compactor:   c,
181 		speechIn:    in,
182 		speechOut:   out,
183-		staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Seed())),
184+		staticChars: utf8.RuneCountInString(llm.GameSystemPrompt(state.Brief())),
185 		budget:      llm.DefaultHistoryPolicy().PromptCharBudget,
186 	}
187 }
188diff --git a/internal/game/state.go b/internal/game/state.go
189index 4a5b071f09f452f26b614135e920218d74d71b66..b3b116441fdd2e5e5a0da215e3ae1138b1c39338 100644
190--- a/internal/game/state.go
191+++ b/internal/game/state.go
192@@ -50,25 +50,35 @@ type DisplayEntry struct {
193 	ShowEnglish  bool
194 }
195 
196+// sheetEntry is one stored character sheet, kept in first-met order so injected
197+// lines stay stable across turns (KV cache prefix). location and talk are
198+// normalized (trimmed + lowercased); text is the canonical single-line sheet.
199+type sheetEntry struct {
200+	location string
201+	talk     string
202+	text     string
203+}
204+
205 // State is the pure, in-memory session. The LLM owns world consistency; State
206 // owns only segmentation (location boundaries), conversation (last TALK), the
207 // over-long-segment compaction bookkeeping, and the display log. No I/O here.
208 type State struct {
209-	seed    string
210+	brief   string
211 	talk    string
212 	closed  []Segment
213 	open    Segment
214 	display []DisplayEntry
215+	sheets  []sheetEntry
216 }
217 
218-// NewState builds a session from an authored world seed. The open segment is
219+// NewState builds a session from the scenario brief. The open segment is
220 // empty until the first reply sets its location.
221-func NewState(seed string) *State {
222-	return &State{seed: seed, talk: "none"}
223+func NewState(brief string) *State {
224+	return &State{brief: brief, talk: "none"}
225 }
226 
227-// Seed returns the authored world seed (for building the system prompt/warmup).
228-func (s *State) Seed() string { return s.seed }
229+// Brief returns the scenario brief (for building the system prompt/warmup).
230+func (s *State) Brief() string { return s.brief }
231 
232 // Location returns the player's current location id ("" before the first reply).
233 func (s *State) Location() string { return s.open.Location }
234@@ -147,12 +157,50 @@ func (s *State) RegisterSummary(location, summary string) {
235 	s.closed = append(s.closed, Segment{Location: location, Summary: summary})
236 }
237 
238+// RegisterSheet stores one character sheet in first-met order. The key is the
239+// normalized location + talk pair; if that pair already has a sheet this is a
240+// no-op (the first sheet wins as the consistency anchor).
241+func (s *State) RegisterSheet(location, talk, text string) {
242+	loc := normalizeLocation(location)
243+	tk := normalizeLocation(talk)
244+	for _, e := range s.sheets {
245+		if e.location == loc && e.talk == tk {
246+			return
247+		}
248+	}
249+	s.sheets = append(s.sheets, sheetEntry{location: loc, talk: tk, text: text})
250+}
251+
252+// SheetFor returns the stored sheet for a location + talk pair and whether it
253+// exists. Both inputs are normalized before lookup.
254+func (s *State) SheetFor(location, talk string) (string, bool) {
255+	loc := normalizeLocation(location)
256+	tk := normalizeLocation(talk)
257+	for _, e := range s.sheets {
258+		if e.location == loc && e.talk == tk {
259+			return e.text, true
260+		}
261+	}
262+	return "", false
263+}
264+
265+// sheetsAt returns the stored sheets for one normalized location, in order.
266+func (s *State) sheetsAt(location string) []sheetEntry {
267+	var out []sheetEntry
268+	for _, e := range s.sheets {
269+		if e.location == location {
270+			out = append(out, e)
271+		}
272+	}
273+	return out
274+}
275+
276 // Prompt reconstructs the full game-loop message list for the next turn: the
277 // system prompt, then (only when prior context exists) one user "context" message
278 // holding all closed-segment recaps plus the open segment's prefix, then the open
279 // segment's raw messages. Pure; it does not mutate state.
280 func (s *State) Prompt() []llm.Message {
281-	msgs := []llm.Message{{Role: llm.RoleSystem, Content: llm.GameSystemPrompt(s.seed)}}
282+	msgs := []llm.Message{{Role: llm.RoleSystem, Content: llm.GameSystemPrompt(s.brief)}}
283 
284 	var ctx strings.Builder
285 	for _, seg := range s.closed {
286@@ -160,6 +208,12 @@ func (s *State) Prompt() []llm.Message {
287 			fmt.Fprintf(&ctx, "Recap (%s): %s\n", seg.Location, seg.Summary)
288 		}
289 	}
290+	if sheets := s.sheetsAt(s.open.Location); len(sheets) > 0 {
291+		ctx.WriteString("Character sheets (internal; never quote or describe these):\n")
292diff --git a/internal/llm/client.go b/internal/llm/client.go
293index 4800a7ae2a2e59a9f0f8bc4b45fd41d735c8c4ab..c13cc8c70c26bc30c075a04836496c1ee958bb0b 100644
294--- a/internal/llm/client.go
295+++ b/internal/llm/client.go
296@@ -37,6 +37,9 @@ type Client struct {
297 	Temperature    float64
298 	MaxTokens      int
299 	EnableThinking bool
300+	// Slot pins every request (including warmup) to a specific llama-server
301+	// slot via the id_slot field. -1 lets the server choose.
302+	Slot int
303 }
304 
305 func NewClient(cfg *config.Config, hc *http.Client) *Client {
306@@ -61,6 +64,7 @@ type chatRequest struct {
307 	MaxTokens          int            `json:"max_tokens"`
308 	Stream             bool           `json:"stream"`
309 	CachePrompt        bool           `json:"cache_prompt"`
310+	IDSlot             int            `json:"id_slot"`
311 }
312 
313 type sseDelta struct {
314@@ -103,6 +107,7 @@ func (c *Client) chat(ctx context.Context, msgs []Message, maxTokens int) (strin
315 		MaxTokens:          maxTokens,
316 		Stream:             true,
317 		CachePrompt:        true,
318+		IDSlot:             c.Slot,
319 	}
320 	payload, err := json.Marshal(body)
321 	if err != nil {
322diff --git a/internal/llm/contract.go b/internal/llm/contract.go
323index 6d4007a18388946bd2973fbfb10d0423443fbb8a..f6bf6cc4617d20944c1ad9f9ab343a663a917fb3 100644
324--- a/internal/llm/contract.go
325+++ b/internal/llm/contract.go
326@@ -49,6 +49,7 @@ func kindString(k ContractErrorKind) string {
327 var (
328 	judgeFieldNames = []string{"SCORE", "ROMAJI", "FEEDBACK"}
329 	gameFieldNames  = []string{"LOCATION", "TALK", "DESC", "ROMAJI", "KANA", "ENGLISH"}
330+	sheetFieldNames = []string{"ROLE", "TRAITS", "MOOD", "STYLE", "UNCLEAR", "SAMPLE"}
331 )
332 
333 // parseFields splits a raw model reply into FIELD|value lines. Surrounding
334@@ -167,3 +168,44 @@ func ParseGameReply(raw string) (GameReply, error) {
335 		English:  vals["ENGLISH"],
336 	}, nil
337 }
338+
339+// Sheet is one person's compact character profile, parsed from the sheet
340+// contract. All six fields are required and non-empty.
341+type Sheet struct {
342+	Role    string // role, age range, one identifying appearance phrase
343+	Traits  string // two to three personality traits
344+	Mood    string // mood today plus what they want or need right now
345+	Style   string // communication style: sentence length, formality, energy
346+	Unclear string // how they handle unclear input: confirm, press on, impatient
347+	Sample  string // one short sample line in their voice, romaji
348+}
349+
350+// ParseSheet parses the six-field character-sheet contract. All six fields are
351+// required and non-empty; a malformed reply is a recoverable *ContractError.
352+func ParseSheet(raw string) (Sheet, error) {
353+	vals, err := parseFields(raw, sheetFieldNames)
354+	if err != nil {
355+		return Sheet{}, err
356+	}
357+	if err := requireFields(vals, sheetFieldNames); err != nil {
358+		return Sheet{}, err
359+	}
360+	return Sheet{
361+		Role:    vals["ROLE"],
362+		Traits:  vals["TRAITS"],
363+		Mood:    vals["MOOD"],
364+		Style:   vals["STYLE"],
365+		Unclear: vals["UNCLEAR"],
366+		Sample:  vals["SAMPLE"],
367+	}, nil
368+}
369+
370+// Text renders the sheet as one space-joined line of its six fields, in order.
371+func (s Sheet) Text() string {
372+	return "ROLE|" + s.Role +
373+		" TRAITS|" + s.Traits +
374+		" MOOD|" + s.Mood +
375+		" STYLE|" + s.Style +
376+		" UNCLEAR|" + s.Unclear +
377+		" SAMPLE|" + s.Sample
378+}
379diff --git a/internal/llm/prompt.go b/internal/llm/prompt.go
380index 50d7eac2d6f1f57d4fd2949f672e72aefa1b7f27..22d2fa5502ad331548e647f636b1d97898c02459 100644
381--- a/internal/llm/prompt.go
382+++ b/internal/llm/prompt.go
383@@ -27,13 +27,6 @@ FEEDBACK|It's a polite greeting, but you can use a more specific greeting for th
384 // JudgeSystemPrompt returns the static judge system message.
385 func JudgeSystemPrompt() string { return judgeSystemTemplate }
386 
387-func fallback(value, def string) string {
388-	if strings.TrimSpace(value) == "" {
389-		return def
390-	}
391-	return value
392-}
393-
394 // BuildJudgeMessages assembles the separate, persona-free judge request from
395 // the raw transcript. It carries no game or NPC wording.
396 func BuildJudgeMessages(transcript string) []Message {
397@@ -43,24 +36,32 @@ func BuildJudgeMessages(transcript string) []Message {
398 	}
399 }
400 
401-// gameSystemTemplate is the game-loop system message. It wraps the authored
402-// world seed and fixes the six-field reply contract for advancing the scene.
403+// gameSystemTemplate is the game-loop system message. It wraps the scenario
404+// brief and fixes the six-field reply contract for advancing the scene.
405diff --git a/internal/scenario/scenario.go b/internal/scenario/scenario.go
406index 7bd52c1df66b78e34b79dac9e1ffde3332915740..d2c2c4757be5357b3c854a123f883916c1348966 100644
407--- a/internal/scenario/scenario.go
408+++ b/internal/scenario/scenario.go
409@@ -1,42 +1,37 @@
410-// Package scenario holds the authored world seed that opens the game-loop LLM
411-// context. A scenario names the town, the starting situation, and a few key
412-// people and places; the model may expand on these facts but must stay
413-// consistent with them.
414+// Package scenario loads the plain-text brief that opens the game-loop LLM
415+// context. A scenario file is one to three sentences describing place, time,
416+// and vibe; the model invents everything else on the fly. The file name (base
417+// without extension) is the scenario name.
418 package scenario
419 
420 import (
421-	"bytes"
422-	"encoding/json"
423 	"fmt"
424 	"os"
425+	"path/filepath"
426 	"strings"
427 )
428 
429-// Scenario is one authored world: a display name plus the seed text that opens
430-// the game-loop system prompt.
431+// Scenario is one brief: a display name plus the text that opens the
432+// game-loop system prompt.
433 type Scenario struct {
434-	Name string `json:"name"`
435-	Seed string `json:"seed"`
436+	Name  string
437+	Brief string
438 }
439 
440-// Load reads and validates a scenario from path. Unknown fields are rejected so
441-// malformed data fails to load with a clear error.
442+// Load reads and validates a scenario from path. Name is the file's base name
443+// without extension; Brief is the trimmed file content. Both must be non-empty.
444 func Load(path string) (Scenario, error) {
445 	data, err := os.ReadFile(path)
446 	if err != nil {
447 		return Scenario{}, fmt.Errorf("scenario: read %s: %w", path, err)
448 	}
449-	var s Scenario
450-	dec := json.NewDecoder(bytes.NewReader(data))
451-	dec.DisallowUnknownFields()
452-	if err := dec.Decode(&s); err != nil {
453-		return Scenario{}, fmt.Errorf("scenario: decode: %w", err)
454-	}
455-	if s.Name == "" {
456+	brief := strings.TrimSpace(string(data))
457+	name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
458+	if name == "" {
459 		return Scenario{}, fmt.Errorf("scenario: name must not be empty")
460 	}
461-	if strings.TrimSpace(s.Seed) == "" {
462-		return Scenario{}, fmt.Errorf("scenario: seed must not be empty")
463+	if brief == "" {
464+		return Scenario{}, fmt.Errorf("scenario: brief must not be empty")
465 	}
466-	return s, nil
467+	return Scenario{Name: name, Brief: brief}, nil
468 }