Clean project layout and fix release workflow
Some checks failed
build-windows-exe / build (push) Failing after 1m13s
Some checks failed
build-windows-exe / build (push) Failing after 1m13s
This commit is contained in:
260
internal/app/world_history.go
Normal file
260
internal/app/world_history.go
Normal file
@@ -0,0 +1,260 @@
|
||||
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)"
|
||||
}
|
||||
Reference in New Issue
Block a user