package chapters import ( "fmt" "math" "sort" "strings" "time" "github.com/AmanTahiliani/box-box/internal/models" ) const ( KindStart = "start" KindSafetyCar = "safety_car" KindVirtualSafetyCar = "virtual_safety_car" KindRedFlag = "red_flag" KindPitPhase = "pit_phase" KindDecisiveSwing = "decisive_swing" KindFinish = "finish" pitPhaseWindowLaps = 3 pitPhaseShare = 0.30 minPitPhaseStops = 2 maxDecisiveSwings = 3 decisiveAfterLap = 5 structuralPriority = 110 flagPriority = 100 pitPhasePriority = 50 decisivePriority = 40 ) type RaceControl = models.RaceControl type PositionSample = models.Position type Lap = models.Lap // Chapter is a deterministic replay segment derived from timing and race-control data. type Chapter struct { Kind string `json:"kind"` Title string `json:"title"` StartLap int `json:"start_lap"` EndLap int `json:"end_lap"` StartTime string `json:"start_time,omitempty"` EndTime string `json:"end_time,omitempty"` DriverNumbers []int `json:"driver_numbers"` } // Detect builds replay chapters from already-loaded race-hub datasets. func Detect(rc []RaceControl, positions []PositionSample, laps []Lap, totalLaps int) []Chapter { totalLaps = normalizeTotalLaps(totalLaps, laps, rc) if totalLaps <= 0 && len(rc) == 0 && len(positions) == 0 && len(laps) == 0 { return []Chapter{} } if totalLaps <= 0 { totalLaps = 1 } lapIndex := buildLapIndex(laps) chapters := []Chapter{ { Kind: KindStart, Title: "Start", StartLap: 1, EndLap: minInt(1, totalLaps), StartTime: lapIndex.lapStart(1), EndTime: lapIndex.lapEnd(1), }, } chapters = append(chapters, detectFlagPeriods(rc, lapIndex, totalLaps)...) chapters = append(chapters, detectPitPhases(laps, lapIndex)...) chapters = append(chapters, detectDecisiveSwings(positions, lapIndex, totalLaps)...) chapters = append(chapters, detectFinish(rc, lapIndex, totalLaps)) return resolveConflicts(chapters) } func normalizeTotalLaps(totalLaps int, laps []Lap, rc []RaceControl) int { for _, l := range laps { if l.LapNumber > totalLaps { totalLaps = l.LapNumber } } for _, msg := range rc { if msg.LapNumber != nil && *msg.LapNumber > totalLaps { totalLaps = *msg.LapNumber } } return totalLaps } type lapIndex struct { byLap map[int]string events []lapEvent } type lapEvent struct { lap int at time.Time } func buildLapIndex(laps []Lap) lapIndex { idx := lapIndex{byLap: map[int]string{}} for _, l := range laps { if l.LapNumber <= 0 || l.DateStart == "" { continue } if _, ok := idx.byLap[l.LapNumber]; !ok { idx.byLap[l.LapNumber] = l.DateStart } at, ok := parseTime(l.DateStart) if ok { idx.events = append(idx.events, lapEvent{lap: l.LapNumber, at: at}) } } sort.Slice(idx.events, func(i, j int) bool { if idx.events[i].at.Equal(idx.events[j].at) { return idx.events[i].lap < idx.events[j].lap } return idx.events[i].at.Before(idx.events[j].at) }) return idx } func (idx lapIndex) lapStart(lap int) string { return idx.byLap[lap] } func (idx lapIndex) lapEnd(lap int) string { if v := idx.byLap[lap+1]; v != "" { return v } return idx.byLap[lap] } func (idx lapIndex) lapForTime(raw string) int { at, ok := parseTime(raw) if !ok || len(idx.events) == 0 { return 0 } lap := 0 for _, event := range idx.events { if event.at.After(at) { break } lap = event.lap } if lap == 0 { return idx.events[0].lap } return lap } type flagState struct { startLap int startTime string } func detectFlagPeriods(rc []RaceControl, idx lapIndex, totalLaps int) []Chapter { var chapters []Chapter active := map[string]flagState{} for _, msg := range rc { kind, ok := flagKind(msg) if !ok && greenFlagClear(msg) { for activeKind, st := range active { lap := messageLap(msg, idx) if lap <= 0 { lap = st.startLap } endLap := clampLap(lap, st.startLap, totalLaps) chapters = append(chapters, Chapter{ Kind: activeKind, Title: flagTitle(activeKind, st.startLap, endLap), StartLap: st.startLap, EndLap: endLap, StartTime: st.startTime, EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)), }) delete(active, activeKind) } continue } if !ok { continue } lap := messageLap(msg, idx) if lap <= 0 { lap = 1 } if flagCleared(msg) { st, ok := active[kind] if !ok { continue } endLap := clampLap(lap, st.startLap, totalLaps) chapters = append(chapters, Chapter{ Kind: kind, Title: flagTitle(kind, st.startLap, endLap), StartLap: st.startLap, EndLap: endLap, StartTime: st.startTime, EndTime: firstNonEmpty(msg.Date, idx.lapEnd(endLap)), }) delete(active, kind) continue } if flagStarted(msg) { active[kind] = flagState{ startLap: clampLap(lap, 1, totalLaps), startTime: firstNonEmpty(msg.Date, idx.lapStart(lap)), } } } for kind, st := range active { endLap := totalLaps chapters = append(chapters, Chapter{ Kind: kind, Title: flagTitle(kind, st.startLap, endLap), StartLap: st.startLap, EndLap: endLap, StartTime: st.startTime, EndTime: idx.lapEnd(endLap), }) } return chapters } func flagKind(msg RaceControl) (string, bool) { text := upperText(string(msg.Category), string(msg.Flag), msg.Message) if strings.Contains(text, "VSC") || strings.Contains(text, "VIRTUAL SAFETY CAR") { return KindVirtualSafetyCar, true } if strings.Contains(text, "RED FLAG") || string(msg.Flag) == string(models.FlagRed) { return KindRedFlag, true } if strings.Contains(text, "SAFETY CAR") || msg.Category == models.CategorySafetyCar { return KindSafetyCar, true } return "", false } func flagStarted(msg RaceControl) bool { text := upperText(string(msg.Category), string(msg.Flag), msg.Message) if strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") { return false } return strings.Contains(text, "DEPLOY") || strings.Contains(text, "RED FLAG") || strings.Contains(text, "VIRTUAL SAFETY CAR") || strings.Contains(text, "VSC") || strings.Contains(text, "SAFETY CAR") || string(msg.Flag) == string(models.FlagRed) } func flagCleared(msg RaceControl) bool { text := upperText(string(msg.Category), string(msg.Flag), msg.Message) return strings.Contains(text, "CLEAR") || strings.Contains(text, "ENDING") || strings.Contains(text, "IN THIS LAP") || strings.Contains(text, "GREEN") } func greenFlagClear(msg RaceControl) bool { text := upperText(string(msg.Flag), msg.Message) return strings.Contains(text, "GREEN") } func flagTitle(kind string, startLap, endLap int) string { name := "Flag period" switch kind { case KindSafetyCar: name = "Safety Car" case KindVirtualSafetyCar: name = "Virtual Safety Car" case KindRedFlag: name = "Red Flag" } return fmt.Sprintf("%s (L%d-L%d)", name, startLap, endLap) } func detectPitPhases(laps []Lap, idx lapIndex) []Chapter { type pitOut struct { lap int driver int } var stops []pitOut for _, l := range laps { if l.IsPitOutLap && l.LapNumber > 0 { stops = append(stops, pitOut{lap: l.LapNumber, driver: l.DriverNumber}) } } if len(stops) < minPitPhaseStops { return nil } sort.Slice(stops, func(i, j int) bool { if stops[i].lap == stops[j].lap { return stops[i].driver < stops[j].driver } return stops[i].lap < stops[j].lap }) needed := int(math.Ceil(float64(len(stops)) * pitPhaseShare)) if needed < minPitPhaseStops { needed = minPitPhaseStops } var windows []Chapter for i := 0; i < len(stops); i++ { start := stops[i].lap end := start + pitPhaseWindowLaps - 1 drivers := map[int]bool{} count := 0 for _, stop := range stops { if stop.lap < start || stop.lap > end { continue } count++ drivers[stop.driver] = true } if count < needed { continue } ch := Chapter{ Kind: KindPitPhase, Title: fmt.Sprintf("Pit phase (L%d-L%d)", start, end), StartLap: start, EndLap: end, StartTime: idx.lapStart(start), EndTime: idx.lapEnd(end), DriverNumbers: sortedDriverNumbers(drivers), } if len(windows) > 0 && ch.StartLap <= windows[len(windows)-1].EndLap+1 { last := &windows[len(windows)-1] if ch.EndLap > last.EndLap { last.EndLap = ch.EndLap last.EndTime = idx.lapEnd(last.EndLap) } drivers := sliceToSet(last.DriverNumbers) for _, driver := range ch.DriverNumbers { drivers[driver] = true } last.DriverNumbers = sortedDriverNumbers(drivers) last.Title = fmt.Sprintf("Pit phase (L%d-L%d)", last.StartLap, last.EndLap) continue } windows = append(windows, ch) } return windows } type swingCandidate struct { chapter Chapter significance int } func detectDecisiveSwings(positions []PositionSample, idx lapIndex, totalLaps int) []Chapter { if len(positions) == 0 || len(idx.events) == 0 || totalLaps <= decisiveAfterLap { return nil } snapshots := buildPositionSnapshots(positions, idx, totalLaps) if len(snapshots) == 0 { return nil } final := snapshots[totalLaps] if len(final) == 0 { for lap := totalLaps - 1; lap >= 1; lap-- { if len(snapshots[lap]) > 0 { final = snapshots[lap] break } } } var candidates []swingCandidate seenDriver := map[int]bool{} for lap := decisiveAfterLap + 1; lap <= totalLaps; lap++ { prev := snapshots[lap-1] curr := snapshots[lap] if len(prev) == 0 || len(curr) == 0 { continue } for driver, pos := range curr { prevPos, ok := prev[driver] if !ok || prevPos <= pos || pos > 5 || pos <= 0 || seenDriver[driver] { continue } finalPos, ok := final[driver] if !ok || finalPos > pos { continue } overtaken := driverAtPosition(curr, prevPos, driver) drivers := []int{driver} if overtaken != 0 { drivers = append(drivers, overtaken) } candidates = append(candidates, swingCandidate{ chapter: Chapter{ Kind: KindDecisiveSwing, Title: fmt.Sprintf("Decisive swing: #%d to P%d (L%d)", driver, pos, lap), StartLap: lap, EndLap: lap, StartTime: idx.lapStart(lap), EndTime: idx.lapEnd(lap), DriverNumbers: drivers, }, significance: (prevPos-pos)*10 + (6 - pos), }) seenDriver[driver] = true } } sort.Slice(candidates, func(i, j int) bool { if candidates[i].significance == candidates[j].significance { return candidates[i].chapter.StartLap < candidates[j].chapter.StartLap } return candidates[i].significance > candidates[j].significance }) if len(candidates) > maxDecisiveSwings { candidates = candidates[:maxDecisiveSwings] } out := make([]Chapter, 0, len(candidates)) for _, c := range candidates { out = append(out, c.chapter) } return out } func buildPositionSnapshots(positions []PositionSample, idx lapIndex, totalLaps int) map[int]map[int]int { byLap := map[int][]PositionSample{} for _, p := range positions { if p.Position <= 0 { continue } lap := idx.lapForTime(p.Date) if lap <= 0 || lap > totalLaps { continue } byLap[lap] = append(byLap[lap], p) } last := map[int]int{} snapshots := map[int]map[int]int{} for lap := 1; lap <= totalLaps; lap++ { for _, p := range byLap[lap] { last[p.DriverNumber] = p.Position } if len(last) == 0 { continue } cp := make(map[int]int, len(last)) for driver, pos := range last { cp[driver] = pos } snapshots[lap] = cp } return snapshots } func driverAtPosition(snapshot map[int]int, pos int, exclude int) int { for driver, driverPos := range snapshot { if driver != exclude && driverPos == pos { return driver } } return 0 } func detectFinish(rc []RaceControl, idx lapIndex, totalLaps int) Chapter { finishLap := totalLaps finishTime := idx.lapEnd(totalLaps) for _, msg := range rc { text := upperText(string(msg.Flag), msg.Message) if strings.Contains(text, "CHEQUER") || string(msg.Flag) == string(models.FlagChequered) { if lap := messageLap(msg, idx); lap > 0 { finishLap = lap } finishTime = firstNonEmpty(msg.Date, finishTime) } } startLap := finishLap - 1 if startLap < 1 { startLap = 1 } return Chapter{ Kind: KindFinish, Title: fmt.Sprintf("Finish (L%d-L%d)", startLap, finishLap), StartLap: startLap, EndLap: finishLap, StartTime: idx.lapStart(startLap), EndTime: finishTime, } } func resolveConflicts(chapters []Chapter) []Chapter { normalized := make([]Chapter, 0, len(chapters)) for _, ch := range chapters { if ch.StartLap <= 0 { ch.StartLap = 1 } if ch.EndLap <= 0 { ch.EndLap = ch.StartLap } if ch.EndLap < ch.StartLap { ch.EndLap = ch.StartLap } if ch.DriverNumbers == nil { ch.DriverNumbers = []int{} } normalized = append(normalized, ch) } sort.SliceStable(normalized, func(i, j int) bool { if normalized[i].StartLap == normalized[j].StartLap { return priority(normalized[i].Kind) > priority(normalized[j].Kind) } return normalized[i].StartLap < normalized[j].StartLap }) out := make([]Chapter, 0, len(normalized)) for _, ch := range normalized { if len(out) == 0 { out = append(out, ch) continue } last := &out[len(out)-1] if ch.StartLap > last.EndLap { out = append(out, ch) continue } if isFlag(last.Kind) && priority(ch.Kind) < priority(last.Kind) { continue } if priority(ch.Kind) > priority(last.Kind) { if last.StartLap < ch.StartLap { last.EndLap = ch.StartLap - 1 out = append(out, ch) } else { *last = ch } continue } if ch.EndLap > last.EndLap { ch.StartLap = last.EndLap + 1 if ch.StartLap <= ch.EndLap { out = append(out, ch) } } } return out } func isFlag(kind string) bool { return kind == KindSafetyCar || kind == KindVirtualSafetyCar || kind == KindRedFlag } func priority(kind string) int { switch kind { case KindStart, KindFinish: return structuralPriority case KindSafetyCar, KindVirtualSafetyCar, KindRedFlag: return flagPriority case KindPitPhase: return pitPhasePriority case KindDecisiveSwing: return decisivePriority default: return 0 } } func messageLap(msg RaceControl, idx lapIndex) int { if msg.LapNumber != nil && *msg.LapNumber > 0 { return *msg.LapNumber } return idx.lapForTime(msg.Date) } func clampLap(lap, minLap, maxLap int) int { if lap < minLap { return minLap } if maxLap > 0 && lap > maxLap { return maxLap } return lap } func parseTime(raw string) (time.Time, bool) { if raw == "" { return time.Time{}, false } at, err := time.Parse(time.RFC3339, raw) if err != nil { return time.Time{}, false } return at, true } func upperText(parts ...string) string { return strings.ToUpper(strings.Join(parts, " ")) } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } func sortedDriverNumbers(drivers map[int]bool) []int { out := make([]int, 0, len(drivers)) for driver := range drivers { out = append(out, driver) } sort.Ints(out) return out } func sliceToSet(values []int) map[int]bool { out := make(map[int]bool, len(values)) for _, value := range values { out[value] = true } return out } func minInt(a, b int) int { if a < b { return a } return b }