aboutsummaryrefslogtreecommitdiff
path: root/internal/game/harness_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/harness_test.go')
-rw-r--r--internal/game/harness_test.go266
1 files changed, 266 insertions, 0 deletions
diff --git a/internal/game/harness_test.go b/internal/game/harness_test.go
new file mode 100644
index 0000000..7898af2
--- /dev/null
+++ b/internal/game/harness_test.go
@@ -0,0 +1,266 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "thehouseoficarus/internal/config"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+// chanConn is a test Conn whose reads come from a channel, so a session's
+// input loop can run on its own goroutine exactly like the real server's
+// connection goroutine. Writes are recorded in a mutex-guarded buffer and may
+// optionally be gated to simulate a slow/stalled client.
+type chanConn struct {
+ mu sync.Mutex
+ out []byte
+ in chan string
+ closed chan struct{}
+ once sync.Once
+
+ // writeGate, when non-nil, blocks every Write until released.
+ writeGate chan struct{}
+}
+
+func newChanConn() *chanConn {
+ return &chanConn{in: make(chan string, 64), closed: make(chan struct{})}
+}
+
+func (c *chanConn) ReadMessage() (string, error) {
+ select {
+ case line := <-c.in:
+ return line, nil
+ case <-c.closed:
+ return "", fmt.Errorf("connection closed")
+ }
+}
+
+func (c *chanConn) Write(b []byte) (int, error) {
+ if c.writeGate != nil {
+ select {
+ case <-c.writeGate:
+ case <-c.closed:
+ return 0, fmt.Errorf("write on closed conn")
+ }
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.out = append(c.out, b...)
+ return len(b), nil
+}
+
+func (c *chanConn) Close() error {
+ c.once.Do(func() { close(c.closed) })
+ return nil
+}
+
+func (c *chanConn) SetWriteDeadline(time.Time) error { return nil }
+
+func (c *chanConn) SetEcho(bool) error { return nil }
+
+func (c *chanConn) Bytes() []byte {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return append([]byte(nil), c.out...)
+}
+
+func (c *chanConn) Contains(substr string) bool {
+ return strings.Contains(string(c.Bytes()), substr)
+}
+
+// send queues an input line for ReadMessage. No-op after close.
+func (c *chanConn) send(line string) {
+ select {
+ case c.in <- line:
+ case <-c.closed:
+ }
+}
+
+// harness wires a real Game (temp data dir) to fake transports driven from
+// per-connection goroutines plus a competing tick goroutine, reproducing the
+// production entry-point concurrency: session input, disconnect callbacks, and
+// tick callbacks all touching shared state at once.
+type harness struct {
+ t *testing.T
+ dir string
+ g *Game
+ hub *net.Hub
+ stop func()
+
+ mu sync.Mutex
+ sessions map[string]*net.Session
+ conns map[string]*chanConn
+}
+
+func newHarness(t *testing.T) *harness {
+ t.Helper()
+ dir := t.TempDir()
+ seedWorld(t, dir)
+ colors := &config.ColorsConfig{}
+ g := New(dir, colors, colors, config.ValidationConfig{}, 1)
+ hub := net.NewHub()
+ g.SetHub(hub)
+
+ stopTick := make(chan struct{})
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ tick := time.NewTicker(2 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ select {
+ case <-tick.C:
+ g.Ticks.Tick()
+ case <-stopTick:
+ return
+ }
+ }
+ }()
+
+ h := &harness{
+ t: t, dir: dir, g: g, hub: hub,
+ sessions: make(map[string]*net.Session),
+ conns: make(map[string]*chanConn),
+ }
+ h.stop = func() { close(stopTick); <-done }
+ t.Cleanup(h.stop)
+ return h
+}
+
+// seedWorld writes the minimal on-disk content a Game needs: three rooms.
+func seedWorld(t *testing.T, dir string) {
+ t.Helper()
+ for _, d := range []string{"rooms", "players/accounts", "players/characters"} {
+ if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil {
+ t.Fatalf("mkdir %s: %v", d, err)
+ }
+ }
+ files := map[string]string{
+ "rooms/1.yaml": "id: 1\nname: Central\nexits:\n north:\n room: 2\n",
+ "rooms/2.yaml": "id: 2\nname: North\nexits:\n south:\n room: 1\n",
+ "rooms/3.yaml": "id: 3\nname: East\n",
+ }
+ for path, content := range files {
+ if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
+ t.Fatalf("seed %s: %v", path, err)
+ }
+ }
+}
+
+// seedAccount writes an account file directly (bypassing the login menu).
+func (h *harness) seedAccount(name, password string, admin bool) {
+ t := h.t
+ hash, err := player.HashPassword(password)
+ if err != nil {
+ t.Fatalf("hash: %v", err)
+ }
+ acc := &player.Account{Name: name, PasswordHash: hash, Admin: admin}
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Fatalf("seed account %s: %v", name, err)
+ }
+}
+
+// seedCharacter writes a character file owned by the named account.
+func (h *harness) seedCharacter(charName, accountName string) *player.Player {
+ t := h.t
+ p := player.New(charName)
+ p.RoomID = 1
+ p.Account = accountName
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Fatalf("seed character %s: %v", charName, err)
+ }
+ acc, err := h.g.AccountStore.LoadAccount(accountName)
+ if err != nil {
+ t.Fatalf("load account %s: %v", accountName, err)
+ }
+ acc.Characters = append(acc.Characters, charName)
+ if err := h.g.AccountStore.SaveAccount(acc); err != nil {
+ t.Fatalf("save account: %v", err)
+ }
+ return p
+}
+
+// connect registers a live in-game session directly, bypassing the login
+// menu. The session is put into queued-write mode (like real transports) and
+// its input is dispatched through the owner entry point, matching production
+// concurrency exactly. Tests that exercise the menu drive handleAccountName
+// themselves.
+func (h *harness) connect(name string) (*net.Session, *chanConn) {
+ t := h.t
+ acc, err := h.g.AccountStore.LoadAccount(name)
+ if err != nil {
+ t.Fatalf("connect %s: %v", name, err)
+ }
+ p, err := h.g.AccountStore.LoadCharacter(name)
+ if err != nil {
+ t.Fatalf("connect %s: %v", name, err)
+ }
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Account: acc, Player: p, State: net.StateGame}
+ sess.StartWriter()
+ h.hub.Add(sess)
+ h.hub.EnterRoom(sess, p.RoomID)
+ h.g.attachSession(sess)
+ h.mu.Lock()
+ h.sessions[name] = sess
+ h.conns[name] = conn
+ h.mu.Unlock()
+ return sess, conn
+}
+
+// connectMenu registers a session authenticated to accountName sitting in
+// the character menu (no character loaded).
+func (h *harness) connectMenu(accountName string) *net.Session {
+ acc, err := h.g.AccountStore.LoadAccount(accountName)
+ if err != nil {
+ h.t.Fatalf("connectMenu %s: %v", accountName, err)
+ }
+ conn := newChanConn()
+ sess := &net.Session{Conn: conn, Account: acc, State: net.StateMenu}
+ sess.StartWriter()
+ h.hub.Add(sess)
+ h.mu.Lock()
+ h.sessions[accountName+"-menu"] = sess
+ h.conns[accountName+"-menu"] = conn
+ h.mu.Unlock()
+ return sess
+}
+
+// barrier blocks until every task submitted so far has executed on the
+// owner goroutine.
+func (h *harness) barrier() {
+ h.g.RunOnOwner(func() {})
+}
+
+// input mirrors net.Server.handleSession: a per-connection goroutine reading
+// lines and dispatching them to HandleSession until the channel closes.
+func (h *harness) input(sess *net.Session, lines <-chan string) {
+ for line := range lines {
+ h.g.HandleSessionOnOwner(sess, line)
+ }
+}
+
+// goInput starts input goroutine(s) for a session, returning the send channel.
+func (h *harness) goInput(sess *net.Session, workers int) chan<- string {
+ lines := make(chan string, 64)
+ for i := 0; i < workers; i++ {
+ go h.input(sess, lines)
+ }
+ return lines
+}
+
+const workers = 1
+
+// sessionFor returns the registered session for a character name.
+func (h *harness) sessionFor(name string) *net.Session {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.sessions[name]
+}