Files
VRCWT-OSC/internal/app/world_history.go
messypy 70bafabee9
All checks were successful
build-windows-exe / build (push) Successful in 1m50s
Persist current world history
2026-07-28 00:51:15 +09:00

267 lines
5.8 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"`
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 = &current
}
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)"
}