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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
|
package game
// Regression coverage for the shutdown final-save report: runShutdown records
// a failed saveAllOnlineCharacters on the Game, and ShutdownSaveError exposes
// it so main can exit non-zero instead of losing online state silently.
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"thehouseoficarus/internal/player"
)
func TestShutdownPreservesOnlineLocationAndExploration(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
p.RoomID = 6101
p.Stats.RecordRoomVisit(6101)
if err := h.g.AccountStore.SaveCharacter(p); err != nil {
t.Fatal(err)
}
sess, _ := h.connect("alice")
wantRooms := []int{6101, 6182, 6183, 6199}
h.g.RunOnOwner(func() {
sess.Player.RoomID = 6199
for _, id := range wantRooms {
sess.Player.Stats.RecordRoomVisit(id)
}
})
// Deliberately leave disk at the older state. The signal shutdown path
// must persist the connected character before closing the transport,
// without requiring the player to quit or disconnect first.
var saved *player.Player
var loadErr error
h.g.SetTransportStop(func() {
saved, loadErr = player.NewAccountStore(h.dir).LoadCharacter("alice")
})
h.g.RequestShutdown("signal")
select {
case <-h.g.ShutdownDone():
case <-time.After(5 * time.Second):
t.Fatal("shutdown did not finish")
}
if err := h.g.ShutdownSaveError(); err != nil {
t.Fatal(err)
}
if loadErr != nil {
t.Fatal(loadErr)
}
if saved == nil {
t.Fatal("transport stop was not called")
}
if saved.RoomID != 6199 || !reflect.DeepEqual(saved.Stats.RoomsVisited.Keys(), wantRooms) {
t.Fatalf("shutdown lost progress: room=%d explored=%v", saved.RoomID, saved.Stats.RoomsVisited.Keys())
}
}
// TestShutdownSaveErrorReportsFailedFinalSave makes the character save fail
// (the character file is replaced by a directory, so the atomic replace cannot
// land) and requires the shutdown sequence to record the failure on the Game.
func TestShutdownSaveErrorReportsFailedFinalSave(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
h.seedCharacter("alice", "alice")
h.connect("alice")
charPath := filepath.Join(h.dir, "players", "characters", "alice.yaml")
if err := os.Remove(charPath); err != nil {
t.Fatalf("remove character file: %v", err)
}
if err := os.Mkdir(charPath, 0o755); err != nil {
t.Fatalf("block character path: %v", err)
}
h.g.runShutdown()
err := h.g.ShutdownSaveError()
if err == nil {
t.Fatal("a failed final save was not recorded")
}
if !strings.Contains(err.Error(), "alice") {
t.Fatalf("shutdown save error does not name the unsaved character: %v", err)
}
}
// TestShutdownSaveErrorNilWhenFinalSaveSucceeds pins the other direction:
// nothing is reported when every online character was persisted, so main does
// not exit non-zero on a clean shutdown.
func TestShutdownSaveErrorNilWhenFinalSaveSucceeds(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
h.seedCharacter("alice", "alice")
h.connect("alice")
if err := h.g.ShutdownSaveError(); err != nil {
t.Fatalf("ShutdownSaveError before any shutdown = %v, want nil", err)
}
h.g.runShutdown()
if err := h.g.ShutdownSaveError(); err != nil {
t.Fatalf("clean shutdown recorded a save failure: %v", err)
}
if _, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
t.Fatalf("online character was not saved: %v", err)
}
}
|