diff options
Diffstat (limited to 'internal/game/harness_test.go')
| -rw-r--r-- | internal/game/harness_test.go | 266 |
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] +} |
