package app import ( "encoding/json" "os" "path/filepath" "sort" "strings" "sync" "time" "vrc_osc_go/internal/common" ) const worldHistoryFileName = "world_history.json" type WorldVisit struct { WorldLabel string `json:"world_label"` WorldID string `json:"world_id,omitempty"` InstanceID string `json:"instance_id,omitempty"` StartedAt time.Time `json:"started_at"` EndedAt time.Time `json:"ended_at,omitempty"` } type worldHistorySnapshot struct { UpdatedAt time.Time `json:"updated_at"` Visits []WorldVisit `json:"visits"` Current *WorldVisit `json:"current,omitempty"` } type WorldHistoryTracker struct { mu sync.RWMutex current *WorldVisit visits []WorldVisit } var worldHistoryTracker *WorldHistoryTracker func InitWorldHistoryTracker(t *WorldHistoryTracker) { worldHistoryTracker = t } func GetWorldHistoryTracker() *WorldHistoryTracker { return worldHistoryTracker } func NewWorldHistoryTracker() *WorldHistoryTracker { t := &WorldHistoryTracker{} _ = t.load() return t } func (t *WorldHistoryTracker) load() error { dir := filepath.Join(common.RootDir(), "runtime") path := filepath.Join(dir, worldHistoryFileName) b, err := os.ReadFile(path) if err != nil { return err } var snap worldHistorySnapshot if err := json.Unmarshal(b, &snap); err != nil { return err } t.visits = mergeWorldVisits(snap.Visits) if snap.Current != nil && !snap.Current.StartedAt.IsZero() { current := *snap.Current t.current = ¤t } return nil } func (t *WorldHistoryTracker) ObserveWorld(worldLabel, worldID, instanceID string, at time.Time) { if t == nil { return } if at.IsZero() { at = time.Now() } label := normalizeWorldLabel(worldLabel, worldID, instanceID) key := worldVisitKey(worldID, instanceID, label) t.mu.Lock() defer t.mu.Unlock() if t.current != nil && t.currentKey() == key { if t.current.WorldLabel == "" { t.current.WorldLabel = label } if t.current.WorldID == "" { t.current.WorldID = worldID } if t.current.InstanceID == "" { t.current.InstanceID = instanceID } return } if t.current != nil { if t.current.EndedAt.IsZero() { t.current.EndedAt = at } t.visits = append(t.visits, *t.current) } t.current = &WorldVisit{ WorldLabel: label, WorldID: worldID, InstanceID: instanceID, StartedAt: at, } _ = t.persistLocked() } func (t *WorldHistoryTracker) FinalizeCurrent(at time.Time) { if t == nil { return } if at.IsZero() { at = time.Now() } t.mu.Lock() defer t.mu.Unlock() if t.current == nil { return } if t.current.EndedAt.IsZero() { t.current.EndedAt = at } t.visits = append(t.visits, *t.current) t.current = nil _ = t.persistLocked() } func (t *WorldHistoryTracker) Current() (WorldVisit, bool) { if t == nil { return WorldVisit{}, false } t.mu.RLock() defer t.mu.RUnlock() if t.current == nil { return WorldVisit{}, false } return *t.current, true } func (t *WorldHistoryTracker) Snapshot() []WorldVisit { if t == nil { return nil } t.mu.RLock() defer t.mu.RUnlock() out := make([]WorldVisit, 0, len(t.visits)+1) out = append(out, mergeWorldVisits(t.visits)...) if t.current != nil { out = append(out, *t.current) } sort.SliceStable(out, func(i, j int) bool { ti := out[i].EndedAt if ti.IsZero() { ti = out[i].StartedAt } tj := out[j].EndedAt if tj.IsZero() { tj = out[j].StartedAt } if ti.Equal(tj) { return strings.ToLower(out[i].WorldLabel) < strings.ToLower(out[j].WorldLabel) } return ti.After(tj) }) return out } func (t *WorldHistoryTracker) currentKey() string { if t.current == nil { return "" } return worldVisitKey(t.current.WorldID, t.current.InstanceID, t.current.WorldLabel) } func (t *WorldHistoryTracker) persistLocked() error { dir := filepath.Join(common.RootDir(), "runtime") if err := os.MkdirAll(dir, 0o755); err != nil { return err } f, err := os.Create(filepath.Join(dir, worldHistoryFileName)) if err != nil { return err } defer f.Close() enc := json.NewEncoder(f) enc.SetIndent("", " ") return enc.Encode(worldHistorySnapshot{ UpdatedAt: time.Now(), Visits: mergeWorldVisits(t.visits), Current: t.current, }) } func mergeWorldVisits(visits []WorldVisit) []WorldVisit { if len(visits) <= 1 { return append([]WorldVisit(nil), visits...) } type bucket struct { visit WorldVisit } seen := make(map[string]int, len(visits)) out := make([]bucket, 0, len(visits)) for _, visit := range visits { key := worldVisitKey(visit.WorldID, visit.InstanceID, visit.WorldLabel) + "|" + visit.StartedAt.UTC().Format(time.RFC3339Nano) if idx, ok := seen[key]; ok { if visit.EndedAt.After(out[idx].visit.EndedAt) { out[idx].visit.EndedAt = visit.EndedAt } if strings.TrimSpace(out[idx].visit.WorldLabel) == "" && strings.TrimSpace(visit.WorldLabel) != "" { out[idx].visit.WorldLabel = visit.WorldLabel } continue } seen[key] = len(out) out = append(out, bucket{visit: visit}) } sort.SliceStable(out, func(i, j int) bool { ti := out[i].visit.EndedAt if ti.IsZero() { ti = out[i].visit.StartedAt } tj := out[j].visit.EndedAt if tj.IsZero() { tj = out[j].visit.StartedAt } if ti.Equal(tj) { return strings.ToLower(out[i].visit.WorldLabel) < strings.ToLower(out[j].visit.WorldLabel) } return ti.After(tj) }) merged := make([]WorldVisit, 0, len(out)) for _, item := range out { merged = append(merged, item.visit) } return merged } func worldVisitKey(worldID, instanceID, worldLabel string) string { if worldID != "" { if instanceID != "" { return worldID + ":" + instanceID } return worldID } return strings.TrimSpace(worldLabel) } func normalizeWorldLabel(worldLabel, worldID, instanceID string) string { label := strings.TrimSpace(worldLabel) if label != "" { return label } if worldID != "" { if instanceID != "" { return worldID + ":" + instanceID } return worldID } return "(unknown)" }