package game import ( "os" "path/filepath" "strconv" "strings" "testing" "thehouseoficarus/internal/combat" "thehouseoficarus/internal/net" "thehouseoficarus/internal/player" "thehouseoficarus/internal/world" ) // removeTestConn is a minimal Conn that records writes for assertions. type removeTestConn struct{ buf []byte } func (c *removeTestConn) ReadMessage() (string, error) { return "", nil } func (c *removeTestConn) Write(b []byte) (int, error) { c.buf = append(c.buf, b...); return len(b), nil } func (c *removeTestConn) Close() error { return nil } func (c *removeTestConn) SetEcho(bool) error { return nil } func (c *removeTestConn) output() string { return string(c.buf) } // newRemoveGame builds a Game with the data stores roomRemove touches, a Hub // (so relocate logic is exercised), and no telephony. Rooms are written into // dir by the caller. func newRemoveGame(t *testing.T, dir string) *Game { t.Helper() g := &Game{ Deps: Deps{ World: world.New(dir), MobStore: world.NewMobStore(dir), AccountStore: player.NewAccountStore(dir), DataDir: dir, }, Hub: net.NewHub(), Combat: combat.NewTracker(), safespot: NewSafespotManager(), } return g } func newRemoveSession(p *player.Player) *net.Session { return &net.Session{Conn: &removeTestConn{}, Player: p, State: net.StateGame} } func writeRemoveRoomFile(t *testing.T, dir string, id int, body string) { t.Helper() roomsDir := filepath.Join(dir, "rooms") if err := os.MkdirAll(roomsDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(roomsDir, strconv.Itoa(id)+".yaml"), []byte(body), 0644); err != nil { t.Fatal(err) } } func assertOutContains(t *testing.T, sess *net.Session, sub string) { t.Helper() out := sess.Conn.(*removeTestConn).output() if !strings.Contains(out, sub) { t.Errorf("expected session output to contain %q, got:\n%s", sub, out) } } func assertOutNotContains(t *testing.T, sess *net.Session, sub string) { t.Helper() out := sess.Conn.(*removeTestConn).output() if strings.Contains(out, sub) { t.Errorf("expected session output to NOT contain %q, got:\n%s", sub, out) } } // reloadAfterCmd re-reads a room from disk after a command has mutated it. func reloadRoom(t *testing.T, g *Game, id int) *world.Room { t.Helper() r, err := g.World.LoadRoom(id) if err != nil { t.Fatalf("reload room %d: %v", id, err) } return r } func TestRoomRemoveSuccessPull(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "You remove #2 (B) and pull #3 (C) back to the east.") // A's east exit now leads to C (3), not B (2). a := reloadRoom(t, g, 1) if a.Exits[world.East].Room != 3 { t.Errorf("A.Exits[east] = %d, want 3", a.Exits[world.East].Room) } // C's west exit now leads to A (1), not B (2). c := reloadRoom(t, g, 3) if c.Exits[world.West].Room != 1 { t.Errorf("C.Exits[west] = %d, want 1", c.Exits[world.West].Room) } // B's file is gone. if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); !os.IsNotExist(err) { t.Errorf("expected 2.yaml to be deleted, got err=%v", err) } } func TestRoomRemoveDiagonalPull(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n northeast: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n northeast: {room: 3}\n southwest: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n southwest: {room: 2}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"northeast"}) assertOutContains(t, sess, "You remove #2 (B) and pull #3 (C) back to the northeast.") a := reloadRoom(t, g, 1) if a.Exits[world.Northeast].Room != 3 { t.Errorf("A.Exits[ne] = %d, want 3", a.Exits[world.Northeast].Room) } c := reloadRoom(t, g, 3) if c.Exits[world.Southwest].Room != 1 { t.Errorf("C.Exits[sw] = %d, want 1", c.Exits[world.Southwest].Room) } } func TestRoomRemoveNoExit(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "There is no exit to the east from here.") } func TestRoomRemoveNotInsertChain(t *testing.T) { dir := t.TempDir() // B does not lead back to A via west. writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "does not lead back here via west") // Nothing was deleted. if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil { t.Errorf("2.yaml should still exist, got err=%v", err) } } func TestRoomRemoveDeadEndFarRoom(t *testing.T) { dir := t.TempDir() // B leads back to A but has no forward east exit. writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n west: {room: 1}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "no room beyond to pull") if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil { t.Errorf("2.yaml should still exist, got err=%v", err) } } func TestRoomRemoveOtherExitsInB(t *testing.T) { dir := t.TempDir() // B has east (fwd), west (back), AND up (extra). writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n up: {room: 4}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n") writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n down: {room: 2}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "has other exits besides west and east") if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil { t.Errorf("2.yaml should still exist, got err=%v", err) } } func TestRoomRemoveFarReciprocityBroken(t *testing.T) { dir := t.TempDir() // C's west does not point back to B. writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "does not lead back to #2") if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil { t.Errorf("2.yaml should still exist, got err=%v", err) } } func TestRoomRemoveExtraInboundEdge(t *testing.T) { dir := t.TempDir() // A clean chain A-east->B-east->C, but an unrelated room 5 also points to B. writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n") writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n north: {room: 2}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "other exit(s) point into #2") if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil { t.Errorf("2.yaml should still exist, got err=%v", err) } } // TestRoomRemoveOverlap mirrors TestRemoveGridConflictsOverlap: removing B // pulls room 6 onto room 5's cell, which must be rejected. func TestRoomRemoveOverlap(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n south: {room: 4}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n south: {room: 6}\n west: {room: 2}\n") writeRemoveRoomFile(t, dir, 4, "name: D\nexits:\n east: {room: 5}\n north: {room: 1}\n") writeRemoveRoomFile(t, dir, 5, "name: E\nexits:\n west: {room: 4}\n") writeRemoveRoomFile(t, dir, 6, "name: F\nexits:\n north: {room: 3}\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) assertOutContains(t, sess, "pulling the far end would cause grid collision") if _, err := os.Stat(filepath.Join(dir, "rooms", "2.yaml")); err != nil { t.Errorf("2.yaml should still exist, got err=%v", err) } } func TestRoomRemoveInvalidDirection(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"sideways"}) assertOutContains(t, sess, "Invalid direction") } func TestRoomRemoveUsage(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n") g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, nil) assertOutContains(t, sess, "Usage: room remove ") } // TestRoomRemovePreservesExitProps confirms the near-side exit's flags travel // onto the repointed A→dir→C edge (and the far side's onto C→oppositeDir→A). func TestRoomRemovePreservesExitProps(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, `name: A exits: east: room: 2 blocked_message: "A rock blocks the way." hidden: true `) writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, `name: C exits: west: room: 2 blocked_message: "The far side is sealed." `) g := newRemoveGame(t, dir) sess := newRemoveSession(&player.Player{Name: "tester", RoomID: 1}) g.roomRemove(sess, []string{"east"}) a := reloadRoom(t, g, 1) if a.Exits[world.East].Room != 3 { t.Errorf("A.Exits[east].Room = %d, want 3", a.Exits[world.East].Room) } if a.Exits[world.East].BlockedMessage != "A rock blocks the way." { t.Errorf("A.Exits[east].BlockedMessage = %q, want preserved", a.Exits[world.East].BlockedMessage) } if !a.Exits[world.East].Hidden { t.Error("A.Exits[east].Hidden should be preserved true") } c := reloadRoom(t, g, 3) if c.Exits[world.West].Room != 1 { t.Errorf("C.Exits[west].Room = %d, want 1", c.Exits[world.West].Room) } if c.Exits[world.West].BlockedMessage != "The far side is sealed." { t.Errorf("C.Exits[west].BlockedMessage = %q, want preserved", c.Exits[world.West].BlockedMessage) } } // TestRoomRemoveRelocatesPlayersInB verifies another session standing in B is // moved to A and notified. The caller stays in A. func TestRoomRemoveRelocatesPlayersInB(t *testing.T) { dir := t.TempDir() writeRemoveRoomFile(t, dir, 1, "name: A\nexits:\n east: {room: 2}\n") writeRemoveRoomFile(t, dir, 2, "name: B\nexits:\n east: {room: 3}\n west: {room: 1}\n") writeRemoveRoomFile(t, dir, 3, "name: C\nexits:\n west: {room: 2}\n") g := newRemoveGame(t, dir) caller := newRemoveSession(&player.Player{Name: "builder", RoomID: 1}) bystander := newRemoveSession(&player.Player{Name: "wanderer", RoomID: 2}) g.Hub.Add(caller) g.Hub.Add(bystander) g.Hub.EnterRoom(caller, 1) g.Hub.EnterRoom(bystander, 2) g.roomRemove(caller, []string{"east"}) if bystander.Player.RoomID != 1 { t.Errorf("bystander RoomID = %d, want 1 (relocated to A)", bystander.Player.RoomID) } assertOutContains(t, bystander, "The room around you collapses") }