package game // Readiness regressions for the account-rename and disconnect paths reviewed // before world/casino expansion: a transport disconnect must be race-free, // renames must not break ownership, and reserved-loot pickups must never // destroy items. import ( "path/filepath" "testing" "thehouseoficarus/internal/net" ) // TestDisconnectRaceIsFreeAndNeverLost drives DisconnectTick (owner) while a // transport goroutine arms the disconnect (exactly net.Server.handleSession's // deferred Hub.Remove path). Guards the atomic disconnect flags: a plain // bool/int pair is a data race and can lose the disconnect entirely. func TestDisconnectRaceIsFreeAndNeverLost(t *testing.T) { h := newHarness(t) h.seedAccount("alice") h.seedCharacter("alice", "alice") sess, _ := h.connect("alice") started, finished := make(chan struct{}), make(chan struct{}) go func() { h.g.RunOnOwner(func() { close(started) for i := 0; i < 10000; i++ { h.g.DisconnectTick() } }) close(finished) }() <-started h.hub.Remove(sess) // transport goroutine; never on the owner <-finished if sessions := h.g.Hub.AllSessions(); len(sessions) != 0 { t.Fatalf("disconnect lost under the race: %d sessions remain", len(sessions)) } } // TestReservedPickupDoesNotDestroyItems: picking up a ground stack that // shares a name with another player's reserved stack must take only the // takeable stock — never half-remove the ground with nothing delivered. func TestReservedPickupDoesNotDestroyItems(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") writeSeedFile(t, filepath.Join(h.dir, "items", "chips.yaml"), []byte("id: chips\nname: chips\nstackable: true\n")) sess := &net.Session{Conn: newChanConn(), Player: p} sess.SetState(net.StateGame) h.g.RunOnOwner(func() { h.g.ItemStore.Reload(h.dir) if _, err := h.g.ItemStore.Load("chips"); err != nil { t.Error(err) return } h.g.World.AddGroundItem(1, "chips", 10) // A reserved stack of the same item sits after the takeable one: // the old mid-loop abort destroyed the already-removed half. h.g.World.AddReservedItem(1, "chips", 10, "bob") h.g.doGet(sess, "chips") total := countChips(p) + h.g.World.GroundItems(1)["chips"] if total != 20 { t.Errorf("pickup destroyed items: inventory+ground=%d, want 20", total) } }) } // TestAccountRenamePreservesAccessAndOwnership: renaming an account // rewrites the owned character files' owner field in the same transaction // and republishes stale sessions (still carrying the old cached name) so // both sessions stay authenticated and keep character access. func TestAccountRenamePreservesAccessAndOwnership(t *testing.T) { h := newHarness(t) s1, s2 := setupAcct(t, h) h.g.RunOnOwner(func() { h.g.handleRenameAccount(s1, "renamed") if !h.g.AccountStore.AccountExists("renamed") { t.Error("rename did not succeed") return } if s1.Account.Name != "renamed" || s2.Account.Name != "renamed" { t.Errorf("sessions remain bound to %q/%q, want renamed/renamed", s1.Account.Name, s2.Account.Name) } if !h.g.isMemberOf("renamed", "alice") { t.Error("renamed account cannot access its own character") } }) }