fix: improve endgame clock and gameplay stability

This commit is contained in:
CAtlas0784 committed 2026-11-04 15:59:48 +07:00
1 parent ce6aabfc55
commit 658298ee86
46 files changed
+1915 -99

No files matched your search

+7 -1
View File
@@ -19,6 +19,9 @@ type CurrencyWarsCommand struct {
Star uint32 `json:"star,omitempty"`
}
// CurrencyWarsCommandEnd requests the same settlement as quitting the run.
const CurrencyWarsCommandEnd = "end"
func ReadCurrencyWarsCommands() ([]CurrencyWarsCommand, error) {
root, err := readCurrencyWarsPersistentRoot()
if err != nil {
@@ -39,9 +42,12 @@ func QueueCurrencyWarsGrant(uid, runID uint32, field string, value, quantity, st
if field == "tb" || field == "trailblaze" {
field = "tbpower"
}
if uid == 0 || runID == 0 || quantity == 0 || (field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
if uid == 0 || runID == 0 || quantity == 0 || (field != CurrencyWarsCommandEnd && field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
return "", fmt.Errorf("invalid CW command")
}
if field == CurrencyWarsCommandEnd && (value != 0 || quantity != 1 || star != 0) {
return "", fmt.Errorf("CW end does not accept arguments")
}
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", err
+52 -16
View File
@@ -11,7 +11,9 @@ import (
// Only game-facing calendar time changes. Network timeouts, diagnostics, leases
// and battle duration measurement continue to use the host's real clock.
type GameClockState struct {
OffsetMS int64 `json:"offset_ms"`
OffsetMS int64 `json:"offset_ms"`
WindowBeginMS int64 `json:"window_begin_ms,omitempty"`
WindowEndMS int64 `json:"window_end_ms,omitempty"`
}
func gameClockPath() (string, error) {
@@ -41,6 +43,9 @@ func ReadGameClock() (GameClockState, error) {
if err := json.Unmarshal(raw, &state); err != nil {
return state, fmt.Errorf("game clock state: %w", err)
}
if (state.WindowBeginMS != 0 || state.WindowEndMS != 0) && (state.WindowBeginMS < 0 || state.WindowEndMS <= state.WindowBeginMS || state.WindowEndMS > 253402300799999) {
return state, fmt.Errorf("invalid game clock window")
}
return state, nil
}
@@ -54,17 +59,18 @@ func GameTime() (time.Time, error) {
return time.Time{}, fmt.Errorf("game clock outside supported calendar range")
}
ms := base + s.OffsetMS
if s.WindowEndMS != 0 {
span := s.WindowEndMS - s.WindowBeginMS
delta := (ms - s.WindowBeginMS) % span
if delta < 0 {
delta += span
}
ms = s.WindowBeginMS + delta
}
return time.UnixMilli(ms), nil
}
func SetGameTime(target *time.Time) (time.Time, error) {
path, err := gameClockPath()
if err != nil {
return time.Time{}, err
}
if path == "" {
return time.Time{}, fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
}
now := time.Now()
state := GameClockState{}
if target != nil {
@@ -73,32 +79,62 @@ func SetGameTime(target *time.Time) (time.Time, error) {
}
state.OffsetMS = target.UnixMilli() - now.UnixMilli()
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
if err := saveGameClockState(state); err != nil {
return time.Time{}, err
}
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
}
// SetGameTimeWindow keeps the shared game calendar inside an observed Client
// interval. It wraps at its end; the host clock and operational timers remain real.
func SetGameTimeWindow(begin, end time.Time) (time.Time, error) {
start, finish := begin.UnixMilli(), end.UnixMilli()
if start < 0 || finish <= start || finish > 253402300799999 {
return time.Time{}, fmt.Errorf("invalid game clock window")
}
now := time.Now()
midpoint := start + (finish-start)/2
state := GameClockState{OffsetMS: midpoint - now.UnixMilli(), WindowBeginMS: start, WindowEndMS: finish}
if err := saveGameClockState(state); err != nil {
return time.Time{}, err
}
return time.UnixMilli(midpoint), nil
}
func saveGameClockState(state GameClockState) error {
path, err := gameClockPath()
if err != nil {
return err
}
if path == "" {
return fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
raw, err := json.MarshalIndent(state, "", " ")
if err != nil {
return time.Time{}, err
return err
}
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
if err != nil {
return time.Time{}, err
return err
}
name := f.Name()
defer os.Remove(name)
if _, err = f.Write(raw); err != nil {
f.Close()
return time.Time{}, err
return err
}
if err = f.Sync(); err != nil {
f.Close()
return time.Time{}, err
return err
}
if err = f.Close(); err != nil {
return time.Time{}, err
return err
}
if err = os.Rename(name, path); err != nil {
return time.Time{}, err
return err
}
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
return nil
}
+34
View File
@@ -42,6 +42,36 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
if string(before) != string(after) {
t.Fatal("read rewrote state")
}
begin := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
end := begin.Add(24 * time.Hour)
if _, err := SetGameTimeWindow(begin, end); err != nil {
t.Fatal(err)
}
window, err := ReadGameClock()
if err != nil || window.WindowBeginMS != begin.UnixMilli() || window.WindowEndMS != end.UnixMilli() {
t.Fatalf("window not saved: %+v %v", window, err)
}
// Simulate the clock having advanced beyond the end without waiting a day.
window.OffsetMS = end.Add(time.Hour).UnixMilli() - time.Now().UnixMilli()
raw, _ = json.Marshal(window)
if err := os.WriteFile(cfg.GameClock.StateFile, raw, 0600); err != nil {
t.Fatal(err)
}
now, err = GameTime()
if err != nil || now.Sub(begin.Add(time.Hour)) < 0 || now.Sub(begin.Add(time.Hour)) > time.Second {
t.Fatalf("window did not wrap: %v %v", now, err)
}
before, _ = os.ReadFile(cfg.GameClock.StateFile)
if _, err := GameTime(); err != nil {
t.Fatal(err)
}
after, _ = os.ReadFile(cfg.GameClock.StateFile)
if string(before) != string(after) {
t.Fatal("window read rewrote state")
}
if _, err := SetGameTimeWindow(end, begin); err == nil {
t.Fatal("invalid window accepted")
}
if _, err := SetGameTime(nil); err != nil {
t.Fatal(err)
}
@@ -49,6 +79,10 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
if err != nil || time.Since(now).Abs() > time.Second {
t.Fatalf("real time not restored: %v %v", now, err)
}
window, err = ReadGameClock()
if err != nil || window.WindowBeginMS != 0 || window.WindowEndMS != 0 {
t.Fatalf("reset retained window: %+v %v", window, err)
}
if err := os.WriteFile(cfg.GameClock.StateFile, []byte(`{"offset_ms":9223372036854775807}`), 0600); err != nil {
t.Fatal(err)
}
+53
View File
@@ -157,6 +157,59 @@ func SaveAvatarRank(id, rank uint32) error {
return SaveAvatarProgress(id, nil, &rank)
}
// MaxAvatarProgress updates the latest roster under the shared save lock,
// preserving unknown fields and all non-avatar data.
func MaxAvatarProgress() (int, error) {
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return 0, err
}
defer unlock()
raw, err := os.ReadFile("freesr-data.json")
if err != nil {
return 0, err
}
var root map[string]json.RawMessage
if err = json.Unmarshal(raw, &root); err != nil || root == nil {
return 0, fmt.Errorf("invalid freesr-data object")
}
var avatars map[string]json.RawMessage
if err = json.Unmarshal(root["avatars"], &avatars); err != nil || avatars == nil {
return 0, fmt.Errorf("invalid avatar roster")
}
for id, entry := range avatars {
var avatar map[string]json.RawMessage
if err = json.Unmarshal(entry, &avatar); err != nil || avatar == nil {
return 0, fmt.Errorf("invalid avatar %s", id)
}
var data map[string]json.RawMessage
if err = json.Unmarshal(avatar["data"], &data); err != nil || data == nil {
return 0, fmt.Errorf("invalid rank data for avatar %s", id)
}
avatar["level"] = json.RawMessage("80")
avatar["promotion"] = json.RawMessage("6")
data["rank"] = json.RawMessage("6")
avatar["data"], err = json.Marshal(data)
if err != nil {
return 0, err
}
avatars[id], err = json.Marshal(avatar)
if err != nil {
return 0, err
}
}
root["avatars"], err = json.Marshal(avatars)
if err != nil {
return 0, err
}
if err = writeJSONAtomic("freesr-data.json", root); err != nil {
return 0, err
}
return len(avatars), nil
}
// SaveAvatarProgress patches only the selected level and/or rank in the latest
// import, under the same cross-process lock used by inventory and gacha saves.
func SaveAvatarProgress(id uint32, level, rank *uint32) error {