aboutsummaryrefslogtreecommitdiff
path: root/internal/game/hardening_shutdown_save_error_test.go
blob: 5842f9bb8a97646b631886accbc08ab5e24ee049 (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
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)
	}
}