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) } }