9df3a9fa0e084cd78a1f355de3b24689730e71e4

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

Message

game: add free-roam state and reveal transitions

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+}