261 lines
5.7 KiB
Go
261 lines
5.7 KiB
Go
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"`
|
|
}
|
|
|
|
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)
|
|
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),
|
|
})
|
|
}
|
|
|
|
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)"
|
|
}
|