aboutsummaryrefslogtreecommitdiff
path: root/internal/game/readiness_regression_test.go
blob: 442bb1fcb19cd2341df05ee339ff702ea4287804 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
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")
		}
	})
}