b99a542751898969b0176acdd0fa74983bbfe0ae

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

Message

data: add city map, locations, and reviewed personas

Diff

This diff is truncated to protect this page.

  1diff --git a/assets/maps/city.json b/assets/maps/city.json
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..c2d94bd1d366de1eb81032ca711b1683ea9ece31
  4--- /dev/null
  5+++ b/assets/maps/city.json
  6@@ -0,0 +1,16 @@
  7+{
  8+  "name": "city",
  9+  "rows": [
 10+    "########",
 11+    "#A.....#",
 12+    "#..S...#",
 13+    "#..T...#",
 14+    "########"
 15+  ],
 16+  "start": { "row": 1, "col": 2 },
 17+  "locations": [
 18+    { "id": "ramen-shop", "marker": "A", "label": "Ramen shop" },
 19+    { "id": "station", "marker": "S", "label": "Station" },
 20+    { "id": "tourist-info", "marker": "T", "label": "Tourist information" }
 21+  ]
 22+}
 23diff --git a/assets/personas/ramen-shop.json b/assets/personas/ramen-shop.json
 24new file mode 100644
 25index 0000000000000000000000000000000000000000..9908b09bdeeae406392817cf760c8d43340b7d1d
 26--- /dev/null
 27+++ b/assets/personas/ramen-shop.json
 28@@ -0,0 +1,11 @@
 29+{
 30+  "name": "Kenji",
 31+  "location": "ramen-shop",
 32+  "personality": "tired, blunt, focused on the pot; does not stop mid-task for small talk",
 33+  "speech_style": "short, plain sentences; answers what is asked and moves on",
 34+  "situation": "You are a cook at a small ramen counter in the evening. A customer approaches the counter to order.",
 35+  "ambiguity": {
 36+    "mode": "carry-on",
 37+    "note": "Carries on with the order after understandable minor mistakes; repeats back a dish or size only when it is genuinely unclear."
 38+  }
 39+}
 40diff --git a/assets/personas/station.json b/assets/personas/station.json
 41new file mode 100644
 42index 0000000000000000000000000000000000000000..af392074fd727cb1e0242903893f6799451f920e
 43--- /dev/null
 44+++ b/assets/personas/station.json
 45@@ -0,0 +1,11 @@
 46+{
 47+  "name": "Sato",
 48+  "location": "station",
 49+  "personality": "precise, careful, methodical; checks details before acting",
 50+  "speech_style": "clear, orderly sentences; states the destination and fare plainly",
 51+  "situation": "You are a clerk at a train station ticket window. A passenger asks about tickets.",
 52+  "ambiguity": {
 53+    "mode": "confirm",
 54+    "note": "Confirms the destination and number of tickets before proceeding when they are not fully clear."
 55+  }
 56+}
 57diff --git a/assets/personas/tourist-info.json b/assets/personas/tourist-info.json
 58new file mode 100644
 59index 0000000000000000000000000000000000000000..d1bf868a8fe0306820c50b4feb4baec44dc50df7
 60--- /dev/null
 61+++ b/assets/personas/tourist-info.json
 62@@ -0,0 +1,11 @@
 63+{
 64+  "name": "Miyu",
 65+  "location": "tourist-info",
 66+  "personality": "friendly, patient, helpful; likes to make visitors feel welcome",
 67+  "speech_style": "warm, conversational sentences; offers simple directions and options",
 68+  "situation": "You are at a tourist information desk in the city centre. A visitor asks where to go.",
 69+  "ambiguity": {
 70+    "mode": "confirm",
 71+    "note": "Asks one short clarifying question only when the place or direction is genuinely unclear; otherwise gives an answer."
 72+  }
 73+}
 74diff --git a/internal/game/map.go b/internal/game/map.go
 75new file mode 100644
 76index 0000000000000000000000000000000000000000..5e6a744f0ea57135d12a1303769dd28cb6f32c25
 77--- /dev/null
 78+++ b/internal/game/map.go
 79@@ -0,0 +1,157 @@
 80+// Package game holds the pure free-roam game state and turn orchestration.
 81+// The map data types here are parsed from JSON asset files; parsing is pure
 82+// and does no I/O, so the rest of the package can stay free of network, audio,
 83+// and UI dependencies.
 84+package game
 85+
 86+import (
 87+	"bytes"
 88+	"encoding/json"
 89+	"fmt"
 90+)
 91+
 92+const (
 93+	blockedCell = '#'
 94+	floorCell   = '.'
 95+)
 96+
 97+// Start is the player's starting cell on the grid.
 98+type Start struct {
 99+	Row int `json:"row"`
100+	Col int `json:"col"`
101+}
102+
103+// LocationDef names one free-roam location: a stable id, the single grid rune
104+// that marks it, and a romaji-only label for the UI.
105+type LocationDef struct {
106+	ID     string `json:"id"`
107+	Marker string `json:"marker"`
108+	Label  string `json:"label"`
109+}
110+
111+// MapData is the parsed city map: the walkable/blocked grid, the player start
112+// cell, and the locations marked on it.
113+type MapData struct {
114+	Name      string        `json:"name"`
115+	Rows      []string      `json:"rows"`
116+	Start     Start         `json:"start"`
117+	Locations []LocationDef `json:"locations"`
118+}
119+
120+// ParseMap decodes and validates a city map from JSON. Unknown fields are
121+// rejected so malformed data fails to load with a clear error.
122+func ParseMap(data []byte) (MapData, error) {
123+	var m MapData
124+	dec := json.NewDecoder(bytes.NewReader(data))
125+	dec.DisallowUnknownFields()
126+	if err := dec.Decode(&m); err != nil {
127+		return MapData{}, fmt.Errorf("map: decode: %w", err)
128+	}
129+	if err := ValidateMap(m); err != nil {
130+		return MapData{}, err
131+	}
132+	return m, nil
133+}
134+
135+// ValidateMap checks the map's structure: a rectangular grid, an in-bounds
136+// walkable start cell, and well-formed unique locations each placed on the grid.
137+func ValidateMap(m MapData) error {
138+	if m.Name == "" {
139+		return fmt.Errorf("map: name must not be empty")
140+	}
141+	if len(m.Rows) == 0 {
142+		return fmt.Errorf("map: rows must not be empty")
143+	}
144+	width := -1
145+	for i, row := range m.Rows {
146+		n := len(row)
147+		if width == -1 {
148+			width = n
149+		} else if n != width {
150+			return fmt.Errorf("map: row %d has length %d, want %d", i, n, width)
151+		}
152+	}
153+	height := len(m.Rows)
154+
155+	if m.Start.Row < 0 || m.Start.Row >= height || m.Start.Col < 0 || m.Start.Col >= width {
156+		return fmt.Errorf("map: start (%d,%d) is out of bounds", m.Start.Row, m.Start.Col)
157+	}
158+	if cellAt(m.Rows, m.Start.Row, m.Start.Col) == blockedCell {
159+		return fmt.Errorf("map: start (%d,%d) is on a blocked cell", m.Start.Row, m.Start.Col)
160+	}
161+
162+	seenID := make(map[string]bool, len(m.Locations))
163+	seenMarker := make(map[rune]string, len(m.Locations))
164+	for i, loc := range m.Locations {
165+		if loc.ID == "" {
166+			return fmt.Errorf("map: location %d id must not be empty", i)
167+		}
168+		if seenID[loc.ID] {
169+			return fmt.Errorf("map: duplicate location id %q", loc.ID)
170+		}
171+		seenID[loc.ID] = true
172+
173+		marker, ok := singleRune(loc.Marker)
174+		if !ok {
175+			return fmt.Errorf("map: location %s marker must be exactly one character, got %q", loc.ID, loc.Marker)
176+		}
177+		if marker == blockedCell || marker == floorCell {
178+			return fmt.Errorf("map: location %s marker %q collides with a grid cell", loc.ID, loc.Marker)
179diff --git a/internal/game/map_test.go b/internal/game/map_test.go
180new file mode 100644
181index 0000000000000000000000000000000000000000..829ff3729573567b880aacc3dc1ff0fc439b7e7e
182--- /dev/null
183+++ b/internal/game/map_test.go
184@@ -0,0 +1,131 @@
185+package game
186+
187+import (
188+	"os"
189+	"path/filepath"
190+	"testing"
191+)
192+
193+const validMapJSON = `{
194+  "name": "city",
195+  "rows": [
196+    "########",
197+    "#A.....#",
198+    "#..S...#",
199+    "#..T...#",
200+    "########"
201+  ],
202+  "start": { "row": 1, "col": 2 },
203+  "locations": [
204+    { "id": "ramen-shop", "marker": "A", "label": "Ramen shop" },
205+    { "id": "station", "marker": "S", "label": "Station" },
206+    { "id": "tourist-info", "marker": "T", "label": "Tourist information" }
207+  ]
208+}`
209+
210+func TestParseValidMap(t *testing.T) {
211+	m, err := ParseMap([]byte(validMapJSON))
212+	if err != nil {
213+		t.Fatalf("ParseMap: %v", err)
214+	}
215+	if m.Name != "city" || len(m.Rows) != 5 || len(m.Locations) != 3 {
216+		t.Errorf("unexpected map: %+v", m)
217+	}
218+}
219+
220+func TestParseMapRejectsRaggedRows(t *testing.T) {
221+	bad := `{"name":"c","rows":["####","#A#","#####"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"}]}`
222+	if _, err := ParseMap([]byte(bad)); err == nil {
223+		t.Errorf("ragged rows should fail")
224+	}
225+}
226+
227+func TestParseMapRejectsOutOfBoundsStart(t *testing.T) {
228+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":5,"col":0},"locations":[{"id":"a","marker":"A","label":"A"}]}`
229+	if _, err := ParseMap([]byte(bad)); err == nil {
230+		t.Errorf("out-of-bounds start should fail")
231+	}
232+}
233+
234+func TestParseMapRejectsBlockedStart(t *testing.T) {
235+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":0,"col":0},"locations":[{"id":"a","marker":"A","label":"A"}]}`
236+	if _, err := ParseMap([]byte(bad)); err == nil {
237+		t.Errorf("start on blocked cell should fail")
238+	}
239+}
240+
241+func TestParseMapRejectsDuplicateMarker(t *testing.T) {
242+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"},{"id":"b","marker":"A","label":"B"}]}`
243+	if _, err := ParseMap([]byte(bad)); err == nil {
244+		t.Errorf("duplicate marker should fail")
245+	}
246+}
247+
248+func TestParseMapRejectsDuplicateID(t *testing.T) {
249+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"},{"id":"a","marker":"B","label":"B"}]}`
250+	if _, err := ParseMap([]byte(bad)); err == nil {
251+		t.Errorf("duplicate id should fail")
252+	}
253+}
254+
255+func TestParseMapRejectsMarkerNotPlaced(t *testing.T) {
256+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"Z","label":"A"}]}`
257+	if _, err := ParseMap([]byte(bad)); err == nil {
258+		t.Errorf("marker not on grid should fail")
259+	}
260+}
261+
262+func TestParseMapRejectsMarkerCollidingWithFloor(t *testing.T) {
263+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":".","label":"A"}]}`
264+	if _, err := ParseMap([]byte(bad)); err == nil {
265+		t.Errorf("marker colliding with floor should fail")
266+	}
267+}
268+
269+func TestParseMapRejectsJapaneseLabel(t *testing.T) {
270+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"ラーメン"}]}`
271+	if _, err := ParseMap([]byte(bad)); err == nil {
272+		t.Errorf("katakana label should fail")
273+	}
274+}
275+
276+func TestParseMapRejectsUnknownField(t *testing.T) {
277+	bad := `{"name":"c","rows":["####","#A#"],"start":{"row":1,"col":1},"locations":[{"id":"a","marker":"A","label":"A"}],"bogus":true}`
278+	if _, err := ParseMap([]byte(bad)); err == nil {
279+		t.Errorf("unknown field should fail")
280+	}
281+}
282+
283+func TestShippedCityMapIsValid(t *testing.T) {
284diff --git a/internal/persona/persona.go b/internal/persona/persona.go
285new file mode 100644
286index 0000000000000000000000000000000000000000..221c2cc6d26b6bb5da6eb096a466c8cc99e68459
287--- /dev/null
288+++ b/internal/persona/persona.go
289@@ -0,0 +1,159 @@
290+// Package persona holds the authored, reviewed persona data that drives NPC
291+// speech in a real-world situation. A persona is stable data: who the person
292+// is, how they speak, the local situation they are in, and how they handle
293+// unclear input based on their personality. Persona content never mentions a
294+// game, player, NPC, quest, or scenario.
295+package persona
296+
297+import (
298+	"bytes"
299+	"encoding/json"
300+	"fmt"
301+	"os"
302+	"path/filepath"
303+	"regexp"
304+	"sort"
305+)
306+
307+// AmbiguityMode is an authored, personality-based way an NPC handles input it
308+// does not fully understand.
309+type AmbiguityMode string
310+
311+const (
312+	// CarryOn means the NPC carries on with the conversation after
313+	// understandable minor mistakes and only repeats back a detail when it is
314+	// genuinely unclear.
315+	CarryOn AmbiguityMode = "carry-on"
316+	// Confirm means the NPC confirms specifics before proceeding when they are
317+	// not fully clear, as a detail-oriented person naturally would.
318+	Confirm AmbiguityMode = "confirm"
319+)
320+
321+var ambiguityModes = map[AmbiguityMode]bool{
322+	CarryOn: true,
323+	Confirm: true,
324+}
325+
326+// Ambiguity captures how this person handles unclear input, shaped by their
327+// personality rather than any teaching role.
328+type Ambiguity struct {
329+	Mode AmbiguityMode `json:"mode"`
330+	Note string        `json:"note"`
331+}
332+
333+// Persona is one reviewed NPC character bound to a single location.
334+type Persona struct {
335+	Name        string    `json:"name"`
336+	Location    string    `json:"location"`
337+	Personality string    `json:"personality"`
338+	SpeechStyle string    `json:"speech_style"`
339+	Situation   string    `json:"situation"`
340+	Ambiguity   Ambiguity `json:"ambiguity"`
341+}
342+
343+var gameContextWord = regexp.MustCompile(`(?i)\b(game|player|npc|quest|scenario)\b`)
344+
345+// Validate checks a persona's required fields and the romaji-only / no-game-
346+// context rules. It returns a clear error naming the offending field.
347+func Validate(p Persona) error {
348+	if p.Name == "" {
349+		return fmt.Errorf("persona: name must not be empty")
350+	}
351+	if hasJapaneseScript(p.Name) {
352+		return fmt.Errorf("persona: name %q must be romaji only, no kana or kanji", p.Name)
353+	}
354+	if p.Location == "" {
355+		return fmt.Errorf("persona: location must not be empty")
356+	}
357+	if p.Personality == "" {
358+		return fmt.Errorf("persona: personality must not be empty")
359+	}
360+	if p.SpeechStyle == "" {
361+		return fmt.Errorf("persona: speech_style must not be empty")
362+	}
363+	if p.Situation == "" {
364+		return fmt.Errorf("persona: situation must not be empty")
365+	}
366+	if !ambiguityModes[p.Ambiguity.Mode] {
367+		return fmt.Errorf("persona: ambiguity.mode %q is not allowed", p.Ambiguity.Mode)
368+	}
369+	if p.Ambiguity.Note == "" {
370+		return fmt.Errorf("persona: ambiguity.note must not be empty")
371+	}
372+	for field, value := range map[string]string{
373+		"personality": p.Personality,
374+		"speech_style": p.SpeechStyle,
375+		"situation":   p.Situation,
376+		"ambiguity.note": p.Ambiguity.Note,
377+	} {
378+		if gameContextWord.MatchString(value) {
379+			return fmt.Errorf("persona: %s mentions game context", field)
380+		}
381+	}
382+	return nil
383+}
384+
385+// Parse decodes and validates one persona from JSON. Unknown fields are
386+// rejected so a typo in the data is a load error, not silent data loss.
387+func Parse(data []byte) (Persona, error) {
388+	var p Persona
389diff --git a/internal/persona/persona_test.go b/internal/persona/persona_test.go
390new file mode 100644
391index 0000000000000000000000000000000000000000..46e4ce6d9030b21f54fd4aca1b2ea977299c38bf
392--- /dev/null
393+++ b/internal/persona/persona_test.go
394@@ -0,0 +1,142 @@
395+package persona
396+
397+import (
398+	"os"
399+	"path/filepath"
400+	"strings"
401+	"testing"
402+)
403+
404+func validPersonaJSON() string {
405+	return `{
406+		"name": "Kenji",
407+		"location": "ramen-shop",
408+		"personality": "tired, blunt, focused on the pot",
409+		"speech_style": "short, plain sentences",
410+		"situation": "You are a cook at a small ramen counter in the evening.",
411+		"ambiguity": { "mode": "carry-on", "note": "Carries on after minor mistakes." }
412+	}`
413+}
414+
415+func TestParseValidPersona(t *testing.T) {
416+	p, err := Parse([]byte(validPersonaJSON()))
417+	if err != nil {
418+		t.Fatalf("Parse: %v", err)
419+	}
420+	if p.Name != "Kenji" || p.Location != "ramen-shop" {
421+		t.Errorf("unexpected persona: %+v", p)
422+	}
423+	if p.Ambiguity.Mode != CarryOn {
424+		t.Errorf("mode = %q, want carry-on", p.Ambiguity.Mode)
425+	}
426+}
427+
428+func TestParseRejectsEmptyRequired(t *testing.T) {
429+	// empty name
430+	if _, err := Parse([]byte(`{"name":"","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
431+		t.Errorf("empty name should fail")
432+	}
433+	if _, err := Parse([]byte(`{"name":"K","location":"","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
434+		t.Errorf("empty location should fail")
435+	}
436+	if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
437+		t.Errorf("empty personality should fail")
438+	}
439+	if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
440+		t.Errorf("empty speech_style should fail")
441+	}
442+	if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
443+		t.Errorf("empty situation should fail")
444+	}
445+	if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":""}}`)); err == nil {
446+		t.Errorf("empty ambiguity.note should fail")
447+	}
448+}
449+
450+func TestParseRejectsJapaneseName(t *testing.T) {
451+	if _, err := Parse([]byte(`{"name":"健司","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
452+		t.Errorf("kanji name should fail")
453+	}
454+	if _, err := Parse([]byte(`{"name":"ケンジ","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)); err == nil {
455+		t.Errorf("katakana name should fail")
456+	}
457+}
458+
459+func TestParseRejectsUnknownField(t *testing.T) {
460+	if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"},"extra":1}`)); err == nil {
461+		t.Errorf("unknown field should fail")
462+	}
463+}
464+
465+func TestParseRejectsBadAmbiguityMode(t *testing.T) {
466+	if _, err := Parse([]byte(`{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"teach","note":"n"}}`)); err == nil {
467+		t.Errorf("unknown ambiguity mode should fail")
468+	}
469+}
470+
471+func TestParseRejectsGameContext(t *testing.T) {
472+	bad := `{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"You are an NPC in a game, on a quest.","ambiguity":{"mode":"carry-on","note":"n"}}`
473+	if _, err := Parse([]byte(bad)); err == nil {
474+		t.Errorf("game-context situation should fail")
475+	}
476+	// "question" must not trip the "quest" check
477+	ok := `{"name":"K","location":"x","personality":"p","speech_style":"s","situation":"A visitor asks a question about the town.","ambiguity":{"mode":"carry-on","note":"n"}}`
478+	if _, err := Parse([]byte(ok)); err != nil {
479+		t.Errorf("word 'question' should not be flagged, got: %v", err)
480+	}
481+}
482+
483+func TestLoadDir(t *testing.T) {
484+	dir := t.TempDir()
485+	mustWrite := func(name, body string) {
486+		if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
487+			t.Fatalf("write fixture: %v", err)
488+		}
489+	}
490+	mustWrite("a.json", `{"name":"K","location":"ramen-shop","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"carry-on","note":"n"}}`)
491+	mustWrite("b.json", `{"name":"S","location":"station","personality":"p","speech_style":"s","situation":"z","ambiguity":{"mode":"confirm","note":"n"}}`)
492+
493+	got, err := LoadDir(dir)