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) SetReadDeadline(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) } // 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 } // Stop the Game's owner goroutine too, or every harness test leaks one // (each holding a 4096-slot channel) for the life of the test binary. // Cleanup is LIFO, so the ticker stops first, then the owner. t.Cleanup(g.stopOwner) t.Cleanup(h.stop) return h } // newHarnessWithTicks is newHarness with the production master tick // subscription registered, so tick-driven systems (disconnect countdown, // action advance, casino tick) run exactly as they do in production. Most // harness tests drive the systems directly and do not need this. func newHarnessWithTicks(t *testing.T) *harness { t.Helper() h := newHarness(t) h.g.SubscribeTicks(1, func() bool { h.g.RunTickSystems() return true }) return h } // Regression: the harness must be able to run the real master tick sequence, // which is the only way the disconnect countdown (Hub.Remove -> DisconnectTick // -> onRemove) is exercised end-to-end. func TestHarnessWithTicksCompletesDisconnect(t *testing.T) { h := newHarnessWithTicks(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") h.hub.Remove(sess) h.barrier() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) && len(h.hub.AllSessions()) > 0 { time.Sleep(5 * time.Millisecond) } if got := len(h.hub.AllSessions()); got != 0 { t.Fatalf("session not hard-removed after the countdown: %d", got) } } // 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 string) { const password = "pw" t := h.t hash, err := player.HashPassword(password) if err != nil { t.Fatalf("hash: %v", err) } acc := &player.Account{Name: name, PasswordHash: hash} 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} sess.SetState(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} sess.SetState(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 the input goroutine for a session, returning the send channel. func (h *harness) goInput(sess *net.Session) chan<- string { lines := make(chan string, 64) go h.input(sess, lines) return lines }