9df3a9fa0e084cd78a1f355de3b24689730e71e4
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/internal/game/state.go b/internal/game/state.go
2new file mode 100644
3index 0000000000000000000000000000000000000000..a7e050f8dc1cff2756b51d1cb7c1f0934905b5f0
4--- /dev/null
5+++ b/internal/game/state.go
6@@ -0,0 +1,204 @@
7+package game
8+
9+// Direction is one grid step the player can attempt.
10+type Direction int
11+
12+const (
13+ Up Direction = iota
14+ Down
15+ Left
16+ Right
17+)
18+
19+func (d Direction) step() (dr, dc int) {
20+ switch d {
21+ case Up:
22+ return -1, 0
23+ case Down:
24+ return 1, 0
25+ case Left:
26+ return 0, -1
27+ case Right:
28+ return 0, 1
29+ }
30+ return 0, 0
31+}
32+
33+// Position is a cell coordinate on the map grid.
34+type Position struct {
35+ Row int
36+ Col int
37+}
38+
39+// Reveal tracks which of an NPC reply's text fields have been shown to the
40+// player. Romaji is always revealed before English.
41+type Reveal int
42+
43+const (
44+ // Hidden: neither romaji nor English has been revealed yet.
45+ Hidden Reveal = iota
46+ // RomajiShown: romaji is visible; English is still hidden.
47+ RomajiShown
48+ // EnglishShown: both romaji and English are visible.
49+ EnglishShown
50+)
51+
52+// Reply is one NPC utterance kept in a location's history. Both text fields
53+// start hidden; Reveal advances romaji first, then English.
54+type Reply struct {
55+ Romaji string
56+ English string
57+ Reveal Reveal
58+}
59+
60+// Turn is one exchange in a location's history: the player's spoken line and
61+// the NPC reply to it. PlayerRaw is the original transcript (for LLM prompts);
62+// PlayerRomaji is the deterministic romaji form shown to the player.
63+type Turn struct {
64+ PlayerRaw string
65+ PlayerRomaji string
66+ NPC Reply
67+}
68+
69+// State is the pure, in-memory free-roam game state: where the player stands,
70+// which location (if any) they are in, and each location's conversation history.
71+// It has no save, score, or progression gate, and does no I/O.
72+type State struct {
73+ m MapData
74+ pos Position
75+ active string
76+ history map[string][]Turn
77+}
78+
79+// NewState builds a state from a parsed map with the player at the start cell.
80+func NewState(m MapData) *State {
81+ return &State{
82+ m: m,
83+ pos: Position{Row: m.Start.Row, Col: m.Start.Col},
84+ history: make(map[string][]Turn),
85+ }
86+}
87+
88+// Map returns the underlying map data for rendering.
89+func (s *State) Map() MapData { return s.m }
90+
91+// Pos returns the player's current cell.
92+func (s *State) Pos() Position { return s.pos }
93+
94+// Move attempts one step in dir. It returns false and leaves the position
95+// unchanged when the target is out of bounds or blocked. Moving onto a location
96+// marker makes that location active; moving off any marker clears it.
97+func (s *State) Move(d Direction) bool {
98+ dr, dc := d.step()
99+ np := Position{Row: s.pos.Row + dr, Col: s.pos.Col + dc}
100+ if !s.walkable(np) {
101+ return false
102+ }
103+ s.pos = np
104+ s.active = s.LocationAt(np)
105+ return true
106diff --git a/internal/game/state_test.go b/internal/game/state_test.go
107new file mode 100644
108index 0000000000000000000000000000000000000000..9959822b5c50d493fb8bbc22d3c7dc24820ae2f6
109--- /dev/null
110+++ b/internal/game/state_test.go
111@@ -0,0 +1,159 @@
112+package game
113+
114+import (
115+ "testing"
116+)
117+
118+func newState(t *testing.T) *State {
119+ t.Helper()
120+ m, err := ParseMap([]byte(validMapJSON))
121+ if err != nil {
122+ t.Fatalf("ParseMap: %v", err)
123+ }
124+ return NewState(m)
125+}
126+
127+func TestMoveBoundsAndWalls(t *testing.T) {
128+ s := newState(t)
129+ if got := s.Pos(); got != (Position{Row: 1, Col: 2}) {
130+ t.Fatalf("start pos = %+v", got)
131+ }
132+ // Up from (1,2) hits the top wall row.
133+ if s.Move(Up) {
134+ t.Errorf("move up into wall should be blocked")
135+ }
136+ if got := s.Pos(); got != (Position{Row: 1, Col: 2}) {
137+ t.Errorf("pos changed on blocked move: %+v", got)
138+ }
139+ // Left from (1,2) to (1,1) which is the ramen marker 'A'.
140+ if !s.Move(Left) {
141+ t.Errorf("move left onto A should succeed")
142+ }
143+ if got := s.Pos(); got != (Position{Row: 1, Col: 1}) {
144+ t.Errorf("pos = %+v, want (1,1)", got)
145+ }
146+ // Left again hits the left wall.
147+ if s.Move(Left) {
148+ t.Errorf("move into left wall should be blocked")
149+ }
150+}
151+
152+func TestEntryAndExit(t *testing.T) {
153+ s := newState(t)
154+ if s.ActiveLocation() != "" {
155+ t.Fatalf("start should not be in a location, got %q", s.ActiveLocation())
156+ }
157+ // Walk down to (2,3) = station 'S'.
158+ if !s.Move(Down) || !s.Move(Right) {
159+ t.Fatalf("expected to reach station")
160+ }
161+ if got := s.ActiveLocation(); got != "station" {
162+ t.Errorf("active = %q, want station", got)
163+ }
164+ // Step off the marker.
165+ if !s.Move(Right) {
166+ t.Fatalf("move right from station should succeed")
167+ }
168+ if got := s.ActiveLocation(); got != "" {
169+ t.Errorf("active after leaving = %q, want empty", got)
170+ }
171+}
172+
173+func TestHistoryPerLocationPersists(t *testing.T) {
174+ s := newState(t)
175+ // Reach station and record a turn.
176+ s.Move(Down)
177+ s.Move(Right) // now at station
178+ s.RecordTurn("station", "raw1", "ro-ma-ji1", Reply{Romaji: "r1", English: "e1"})
179+
180+ // Wander to ramen shop and record there, then back to open space.
181+ s.Move(Up) // (1,3)
182+ s.Move(Left) // (1,2)
183+ s.Move(Left) // (1,1) ramen 'A'
184+ if s.ActiveLocation() != "ramen-shop" {
185+ t.Fatalf("expected ramen-shop, got %q", s.ActiveLocation())
186+ }
187+ s.RecordTurn("ramen-shop", "raw2", "ro-ma-ji2", Reply{Romaji: "r2", English: "e2"})
188+
189+ // Return to station and confirm its history is intact and resumable.
190+ if got := len(s.History("station")); got != 1 {
191+ t.Fatalf("station history = %d, want 1", got)
192+ }
193+ if s.ActiveLocation() == "station" {
194+ t.Fatalf("should have left station before re-entry")
195+ }
196+ s.Move(Down) // (2,1)
197+ s.Move(Right) // (2,2)
198+ s.Move(Right) // (2,3) station 'S'
199+ if s.ActiveLocation() != "station" {
200+ t.Fatalf("expected to be back at station")
201+ }
202+ hist := s.History("station")
203+ if len(hist) != 1 || hist[0].PlayerRaw != "raw1" || hist[0].NPC.Romaji != "r1" {
204+ t.Errorf("station history not resumed correctly: %+v", hist)
205+ }
206+ s.RecordTurn("station", "raw3", "ro-ma-ji3", Reply{Romaji: "r3", English: "e3"})
207+ if got := len(s.History("station")); got != 2 {
208+ t.Errorf("station history after second turn = %d, want 2", got)
209+ }
210+}