b99a542751898969b0176acdd0fa74983bbfe0ae
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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)