mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-08 04:06:18 -04:00
Add local-first refactor foundation
This commit is contained in:
495
internal/live/state.go
Normal file
495
internal/live/state.go
Normal file
@@ -0,0 +1,495 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State accumulates live timing updates from SignalR topic payloads.
|
||||
type State struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
DriverInfo map[string]F1DriverListEntry
|
||||
Tyres map[string]LiveTyreData
|
||||
Stints map[string][]LiveStintData
|
||||
RCMessages []LiveRCMessage
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TrackStatus string
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
Clock string
|
||||
ClockRefTime time.Time
|
||||
ClockExtrapolating bool
|
||||
}
|
||||
|
||||
// NewState returns an empty live timing accumulator.
|
||||
func NewState() *State {
|
||||
return &State{
|
||||
Drivers: make(map[string]LiveDriverData),
|
||||
DriverInfo: make(map[string]F1DriverListEntry),
|
||||
Tyres: make(map[string]LiveTyreData),
|
||||
Stints: make(map[string][]LiveStintData),
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot returns a defensive copy of the current state.
|
||||
func (s *State) Snapshot() LiveStreamData {
|
||||
cpyDrivers := make(map[string]LiveDriverData, len(s.Drivers))
|
||||
for k, v := range s.Drivers {
|
||||
cpyDrivers[k] = v
|
||||
}
|
||||
cpyInfo := make(map[string]F1DriverListEntry, len(s.DriverInfo))
|
||||
for k, v := range s.DriverInfo {
|
||||
cpyInfo[k] = v
|
||||
}
|
||||
cpyTyres := make(map[string]LiveTyreData, len(s.Tyres))
|
||||
for k, v := range s.Tyres {
|
||||
cpyTyres[k] = v
|
||||
}
|
||||
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
|
||||
copy(cpyRC, s.RCMessages)
|
||||
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
|
||||
for k, v := range s.Stints {
|
||||
st := make([]LiveStintData, len(v))
|
||||
copy(st, v)
|
||||
cpyStints[k] = st
|
||||
}
|
||||
|
||||
return LiveStreamData{
|
||||
Drivers: cpyDrivers,
|
||||
DriverInfo: cpyInfo,
|
||||
Tyres: cpyTyres,
|
||||
RCMessages: cpyRC,
|
||||
Weather: s.Weather,
|
||||
Session: s.Session,
|
||||
TrackStatus: s.TrackStatus,
|
||||
CurrentLap: s.CurrentLap,
|
||||
TotalLaps: s.TotalLaps,
|
||||
Clock: s.Clock,
|
||||
ClockRefTime: s.ClockRefTime,
|
||||
ClockExtrapolating: s.ClockExtrapolating,
|
||||
Stints: cpyStints,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
|
||||
func (s *State) ProcessMessage(message []byte) bool {
|
||||
var parsed F1SignalRMessage
|
||||
if err := json.Unmarshal(message, &parsed); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
updated := false
|
||||
|
||||
if len(parsed.R) > 2 {
|
||||
var rMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
|
||||
for topic, data := range rMap {
|
||||
if s.ProcessTopic(topic, data) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range parsed.M {
|
||||
if len(m.A) > 1 {
|
||||
var topic string
|
||||
json.Unmarshal(m.A[0], &topic)
|
||||
if s.ProcessTopic(topic, m.A[1]) {
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
// ProcessTopic applies a single topic payload to the accumulator.
|
||||
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
updated := false
|
||||
switch topic {
|
||||
case "TimingData":
|
||||
var td struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
}
|
||||
if json.Unmarshal(data, &td) == nil {
|
||||
for num, lineRaw := range td.Lines {
|
||||
var line F1TimingLine
|
||||
if json.Unmarshal(lineRaw, &line) == nil {
|
||||
updateDriver(s.Drivers, num, line)
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case "DriverList":
|
||||
var dlMap map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &dlMap) == nil {
|
||||
for num, entryRaw := range dlMap {
|
||||
var entry F1DriverListEntry
|
||||
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
|
||||
s.DriverInfo[num] = entry
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case "LapCount":
|
||||
var lc struct {
|
||||
CurrentLap json.Number `json:"CurrentLap"`
|
||||
TotalLaps json.Number `json:"TotalLaps"`
|
||||
}
|
||||
if json.Unmarshal(data, &lc) == nil {
|
||||
if v, err := lc.CurrentLap.Int64(); err == nil {
|
||||
s.CurrentLap = int(v)
|
||||
}
|
||||
if v, err := lc.TotalLaps.Int64(); err == nil {
|
||||
s.TotalLaps = int(v)
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
case "ExtrapolatedClock":
|
||||
var ec struct {
|
||||
Remaining string `json:"Remaining"`
|
||||
Utc string `json:"Utc"`
|
||||
Extrapolating bool `json:"Extrapolating"`
|
||||
}
|
||||
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
|
||||
s.Clock = ec.Remaining
|
||||
s.ClockExtrapolating = ec.Extrapolating
|
||||
if ec.Utc != "" {
|
||||
if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil {
|
||||
s.ClockRefTime = t
|
||||
} else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil {
|
||||
s.ClockRefTime = t
|
||||
} else {
|
||||
s.ClockRefTime = time.Now()
|
||||
}
|
||||
} else {
|
||||
s.ClockRefTime = time.Now()
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
case "TrackStatus":
|
||||
var ts struct {
|
||||
Status string `json:"Status"`
|
||||
Message string `json:"Message"`
|
||||
}
|
||||
if json.Unmarshal(data, &ts) == nil && ts.Status != "" {
|
||||
s.TrackStatus = ts.Status
|
||||
updated = true
|
||||
}
|
||||
case "RaceControlMessages":
|
||||
var rcm struct {
|
||||
Messages map[string]json.RawMessage `json:"Messages"`
|
||||
}
|
||||
if json.Unmarshal(data, &rcm) == nil {
|
||||
for _, msgRaw := range rcm.Messages {
|
||||
var msg struct {
|
||||
Utc string `json:"Utc"`
|
||||
Category string `json:"Category"`
|
||||
Flag string `json:"Flag"`
|
||||
Message string `json:"Message"`
|
||||
Lap int `json:"Lap"`
|
||||
}
|
||||
if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" {
|
||||
t := ""
|
||||
if len(msg.Utc) >= 19 {
|
||||
t = msg.Utc[11:16]
|
||||
}
|
||||
s.RCMessages = append(s.RCMessages, LiveRCMessage{
|
||||
Time: t,
|
||||
Category: msg.Category,
|
||||
Flag: msg.Flag,
|
||||
Message: msg.Message,
|
||||
Lap: msg.Lap,
|
||||
})
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case "WeatherData":
|
||||
var wd struct {
|
||||
AirTemp json.Number `json:"AirTemp"`
|
||||
TrackTemp json.Number `json:"TrackTemp"`
|
||||
Humidity json.Number `json:"Humidity"`
|
||||
WindSpeed json.Number `json:"WindSpeed"`
|
||||
WindDirection json.Number `json:"WindDirection"`
|
||||
Rainfall json.Number `json:"Rainfall"`
|
||||
}
|
||||
if json.Unmarshal(data, &wd) == nil {
|
||||
if v, err := wd.AirTemp.Float64(); err == nil {
|
||||
s.Weather.AirTemp = v
|
||||
}
|
||||
if v, err := wd.TrackTemp.Float64(); err == nil {
|
||||
s.Weather.TrackTemp = v
|
||||
}
|
||||
if v, err := wd.Humidity.Float64(); err == nil {
|
||||
s.Weather.Humidity = v
|
||||
}
|
||||
if v, err := wd.WindSpeed.Float64(); err == nil {
|
||||
s.Weather.WindSpeed = v
|
||||
}
|
||||
if v, err := wd.WindDirection.Int64(); err == nil {
|
||||
s.Weather.WindDir = int(v)
|
||||
}
|
||||
if v, err := wd.Rainfall.Float64(); err == nil {
|
||||
s.Weather.Rainfall = v > 0
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
case "SessionInfo":
|
||||
var si struct {
|
||||
Meeting struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Meeting"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
if json.Unmarshal(data, &si) == nil {
|
||||
if si.Meeting.Name != "" {
|
||||
s.Session.MeetingName = si.Meeting.Name
|
||||
}
|
||||
if si.Name != "" {
|
||||
s.Session.SessionName = si.Name
|
||||
}
|
||||
if si.Type != "" {
|
||||
s.Session.SessionType = si.Type
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
case "CurrentTyres":
|
||||
var ct map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &ct) == nil {
|
||||
for num, raw := range ct {
|
||||
if num == "_kf" {
|
||||
continue
|
||||
}
|
||||
var td struct {
|
||||
Compound string `json:"Compound"`
|
||||
New string `json:"New"`
|
||||
}
|
||||
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
|
||||
t := s.Tyres[num]
|
||||
t.Compound = td.Compound
|
||||
t.New = td.New == "true" || td.New == "True"
|
||||
s.Tyres[num] = t
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
case "TimingAppData":
|
||||
var tad struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
}
|
||||
if json.Unmarshal(data, &tad) == nil {
|
||||
for num, lineRaw := range tad.Lines {
|
||||
var line struct {
|
||||
Stints map[string]json.RawMessage `json:"Stints"`
|
||||
}
|
||||
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
|
||||
var driverStints []LiveStintData
|
||||
for _, sRaw := range line.Stints {
|
||||
var st struct {
|
||||
Compound string `json:"Compound"`
|
||||
New string `json:"New"`
|
||||
TotalLaps int `json:"TotalLaps"`
|
||||
}
|
||||
if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" {
|
||||
driverStints = append(driverStints, LiveStintData{
|
||||
Compound: st.Compound,
|
||||
New: st.New == "true" || st.New == "True",
|
||||
Laps: st.TotalLaps,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(driverStints) > 0 {
|
||||
s.Stints[num] = driverStints
|
||||
lastStint := driverStints[len(driverStints)-1]
|
||||
t := s.Tyres[num]
|
||||
t.Age = lastStint.Laps
|
||||
if lastStint.Compound != "" {
|
||||
t.Compound = lastStint.Compound
|
||||
t.New = lastStint.New
|
||||
}
|
||||
s.Tyres[num] = t
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "TimingStats":
|
||||
var ts struct {
|
||||
Lines map[string]json.RawMessage `json:"Lines"`
|
||||
}
|
||||
if json.Unmarshal(data, &ts) == nil {
|
||||
for num, lineRaw := range ts.Lines {
|
||||
var line struct {
|
||||
PersonalBestLapTime struct {
|
||||
Value string `json:"Value"`
|
||||
} `json:"PersonalBestLapTime"`
|
||||
}
|
||||
if json.Unmarshal(lineRaw, &line) == nil {
|
||||
if d, ok := s.Drivers[num]; ok && line.PersonalBestLapTime.Value != "" {
|
||||
d.BestLapTime = line.PersonalBestLapTime.Value
|
||||
s.Drivers[num] = d
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
|
||||
d, exists := drivers[num]
|
||||
if !exists {
|
||||
d = LiveDriverData{RacingNumber: num}
|
||||
if line.RacingNumber != "" {
|
||||
d.RacingNumber = line.RacingNumber
|
||||
}
|
||||
}
|
||||
|
||||
if line.Position != nil {
|
||||
var newPos int
|
||||
switch v := line.Position.(type) {
|
||||
case string:
|
||||
fmt.Sscanf(v, "%d", &newPos)
|
||||
case float64:
|
||||
newPos = int(v)
|
||||
}
|
||||
if newPos > 0 && newPos != d.Position {
|
||||
d.PrevPosition = d.Position
|
||||
d.Position = newPos
|
||||
}
|
||||
}
|
||||
if line.GapToLeader != nil {
|
||||
if s := extractStringVal(line.GapToLeader); s != "" {
|
||||
d.GapToLeader = s
|
||||
}
|
||||
}
|
||||
if line.IntervalToPositionAhead.Value != nil {
|
||||
if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" {
|
||||
d.Interval = s
|
||||
}
|
||||
}
|
||||
if line.LastLapTime.Value != "" {
|
||||
d.LastLapTime = line.LastLapTime.Value
|
||||
d.LastLapPB = line.LastLapTime.PersonalFastest
|
||||
d.LastLapOB = line.LastLapTime.OverallFastest
|
||||
}
|
||||
if line.BestLapTime.Value != "" {
|
||||
d.BestLapTime = line.BestLapTime.Value
|
||||
d.BestLapPB = line.BestLapTime.PersonalFastest
|
||||
d.BestLapOB = line.BestLapTime.OverallFastest
|
||||
if line.BestLapTime.Lap > 0 {
|
||||
d.BestLapNum = line.BestLapTime.Lap
|
||||
}
|
||||
}
|
||||
if line.InPit != nil {
|
||||
d.InPit = toBool(line.InPit)
|
||||
}
|
||||
if line.PitOut != nil {
|
||||
d.PitOut = toBool(line.PitOut)
|
||||
}
|
||||
if line.Retired != nil {
|
||||
d.Retired = toBool(line.Retired)
|
||||
}
|
||||
if line.KnockedOut != nil {
|
||||
d.KnockedOut = toBool(line.KnockedOut)
|
||||
}
|
||||
if line.Cutoff != nil {
|
||||
d.Cutoff = toBool(line.Cutoff)
|
||||
}
|
||||
if line.NumberOfLaps != nil {
|
||||
if v, ok := toInt(line.NumberOfLaps); ok {
|
||||
d.NumberOfLaps = v
|
||||
}
|
||||
}
|
||||
|
||||
if st, ok := line.Speeds["ST"]; ok {
|
||||
var sp struct {
|
||||
Value string `json:"Value"`
|
||||
}
|
||||
if json.Unmarshal(st, &sp) == nil && sp.Value != "" {
|
||||
d.SpeedTrap = sp.Value
|
||||
}
|
||||
}
|
||||
|
||||
for idx, sRaw := range line.Sectors {
|
||||
i := 0
|
||||
fmt.Sscanf(idx, "%d", &i)
|
||||
if i >= 0 && i < 3 {
|
||||
var sec struct {
|
||||
Value string `json:"Value"`
|
||||
PersonalFastest bool `json:"PersonalFastest"`
|
||||
OverallFastest bool `json:"OverallFastest"`
|
||||
}
|
||||
if json.Unmarshal(sRaw, &sec) == nil {
|
||||
if sec.Value == "" {
|
||||
d.Sectors[i] = LiveSectorData{}
|
||||
} else {
|
||||
d.Sectors[i] = LiveSectorData{
|
||||
Value: sec.Value,
|
||||
PersonalFastest: sec.PersonalFastest,
|
||||
OverallFastest: sec.OverallFastest,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.OnFlyingLap = !d.InPit && !d.Retired &&
|
||||
(d.Sectors[0].Value != "" || d.Sectors[1].Value != "") &&
|
||||
d.Sectors[2].Value == ""
|
||||
|
||||
drivers[num] = d
|
||||
}
|
||||
|
||||
func extractStringVal(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case float64:
|
||||
if val == 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("+%.3f", val)
|
||||
case map[string]interface{}:
|
||||
if s, ok := val["Value"].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toBool(v interface{}) bool {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case string:
|
||||
return val == "true" || val == "True"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toInt(v interface{}) (int, bool) {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return int(val), true
|
||||
case json.Number:
|
||||
if i, err := val.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
case string:
|
||||
var i int
|
||||
if _, err := fmt.Sscanf(val, "%d", &i); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
Reference in New Issue
Block a user