Files
box-box/internal/chapters/headlines.go
AmanTahiliani 5b112fb012 feat: add narrative chapter headlines and race-hub chapter strip (#21)
Generate deterministic template headlines server-side for each replay chapter
kind, expose Chapter.headline in the race-hub payload, and render a horizontal
chapter strip on the story view with active-chapter highlighting, click-to-jump,
and a 90-second tour mode built on the existing scrubber playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 18:23:11 -04:00

234 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package chapters
import (
"fmt"
"strings"
"github.com/AmanTahiliani/box-box/internal/models"
)
// DriverIdentityInput carries session driver fields used for headline templates.
type DriverIdentityInput struct {
DriverNumber int
NameAcronym string
FullName string
TeamName string
}
type driverIdentity struct {
display string
team string
acronym string
}
// BuildDriverMap indexes driver identity from session drivers and enriched results.
func BuildDriverMap(drivers []models.Driver, results []DriverIdentityInput) map[int]driverIdentity {
out := map[int]driverIdentity{}
for _, d := range drivers {
if d.DriverNumber <= 0 {
continue
}
out[d.DriverNumber] = driverIdentity{
display: driverDisplayName(d.LastName, d.FullName, d.NameAcronym, d.BroadcastName),
team: d.TeamName,
acronym: firstNonEmpty(d.NameAcronym, d.BroadcastName),
}
}
for _, r := range results {
if r.DriverNumber <= 0 {
continue
}
if _, ok := out[r.DriverNumber]; ok {
continue
}
out[r.DriverNumber] = driverIdentity{
display: driverDisplayName("", r.FullName, r.NameAcronym, ""),
team: r.TeamName,
acronym: r.NameAcronym,
}
}
return out
}
// ApplyHeadlines fills Headline on each chapter using deterministic templates.
func ApplyHeadlines(chapters []Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) []Chapter {
out := make([]Chapter, len(chapters))
copy(out, chapters)
for i := range out {
out[i].Headline = headlineFor(out[i], drivers, rc, winnerNumber)
}
return out
}
func headlineFor(ch Chapter, drivers map[int]driverIdentity, rc []RaceControl, winnerNumber int) string {
switch ch.Kind {
case KindStart:
return pickVariant(ch.StartLap,
"Lights out — the field charges into Turn 1",
"Race start — Lap 1 shuffle at the front",
)
case KindSafetyCar:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s incident brings out the Safety Car — leaders dive for the pits", name),
fmt.Sprintf("Safety Car deployed after %s stops on track", name),
)
}
case KindVirtualSafetyCar:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s off track triggers the Virtual Safety Car", name),
fmt.Sprintf("Virtual Safety Car — %s loses control on Lap %d", name, ch.StartLap),
)
}
return pickVariant(ch.StartLap,
fmt.Sprintf("Virtual Safety Car deployed on Lap %d", ch.StartLap),
fmt.Sprintf("VSC period — field backs off on L%dL%d", ch.StartLap, ch.EndLap),
)
case KindRedFlag:
if name := driverName(ch, drivers, incidentDriver(rc, ch.StartLap)); name != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s crash forces a red flag on Lap %d", name, ch.StartLap),
fmt.Sprintf("Red flag after %s incident on Lap %d", name, ch.StartLap),
)
}
return pickVariant(ch.StartLap,
fmt.Sprintf("Red flag — session halted on Lap %d", ch.StartLap),
fmt.Sprintf("Race suspended under red flag on L%dL%d", ch.StartLap, ch.EndLap),
)
case KindPitPhase:
names := driverNames(ch.DriverNumbers, drivers, 3)
if len(names) >= 2 {
return pickVariant(ch.StartLap,
fmt.Sprintf("Mass pit-window scramble — %s and %s box on L%dL%d", names[0], names[1], ch.StartLap, ch.EndLap),
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%dL%d", names[0], ch.StartLap, ch.EndLap),
)
}
if len(names) == 1 {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s pits under green — strategy window opens on L%d", names[0], ch.StartLap),
fmt.Sprintf("Pit phase on L%dL%d — %s among the first to stop", ch.StartLap, ch.EndLap, names[0]),
)
}
case KindDecisiveSwing:
if len(ch.DriverNumbers) >= 2 {
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
defender := driverName(ch, drivers, ch.DriverNumbers[1])
pos := swingPosition(ch.Title)
if attacker != "" && defender != "" && pos > 0 {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s overtakes %s for P%d", attacker, defender, pos),
fmt.Sprintf("%s charges past %s into P%d on Lap %d", attacker, defender, pos, ch.StartLap),
)
}
}
if len(ch.DriverNumbers) >= 1 {
attacker := driverName(ch, drivers, ch.DriverNumbers[0])
pos := swingPosition(ch.Title)
if attacker != "" && pos > 0 {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s moves up to P%d on Lap %d", attacker, pos, ch.StartLap),
fmt.Sprintf("Decisive swing — %s climbs to P%d", attacker, pos),
)
}
}
case KindFinish:
winner := driverName(ch, drivers, winnerNumber)
if winner != "" {
return pickVariant(ch.StartLap,
fmt.Sprintf("%s crosses the line to take the win", winner),
fmt.Sprintf("Chequered flag — %s wins the race", winner),
)
}
}
return ch.Title
}
func pickVariant(seed int, variants ...string) string {
if len(variants) == 0 {
return ""
}
if seed < 0 {
seed = -seed
}
return variants[seed%len(variants)]
}
func driverName(_ Chapter, drivers map[int]driverIdentity, number int) string {
if number <= 0 {
return ""
}
if id, ok := drivers[number]; ok && id.display != "" {
return id.display
}
return ""
}
func driverNames(numbers []int, drivers map[int]driverIdentity, limit int) []string {
out := make([]string, 0, limit)
for _, number := range numbers {
if name := driverName(Chapter{}, drivers, number); name != "" {
out = append(out, name)
if len(out) >= limit {
break
}
}
}
return out
}
func driverDisplayName(lastName, fullName, acronym, broadcast string) string {
if lastName != "" {
return lastName
}
if fullName != "" {
parts := strings.Fields(fullName)
if len(parts) > 0 {
return parts[len(parts)-1]
}
return fullName
}
return firstNonEmpty(acronym, broadcast)
}
func incidentDriver(rc []RaceControl, startLap int) int {
for _, msg := range rc {
if msg.DriverNumber == nil || *msg.DriverNumber <= 0 {
continue
}
lap := 0
if msg.LapNumber != nil {
lap = *msg.LapNumber
}
if lap < startLap-1 || lap > startLap+1 {
continue
}
text := upperText(msg.Message, string(msg.Category))
if strings.Contains(text, "DEPLOY") ||
strings.Contains(text, "CLEAR") ||
strings.Contains(text, "ENDING") ||
strings.Contains(text, "GREEN") ||
strings.Contains(text, "CHEQUER") {
continue
}
return *msg.DriverNumber
}
return 0
}
func swingPosition(title string) int {
// Title format: "Decisive swing: #16 to P3 (L6)"
idx := strings.Index(title, "P")
if idx < 0 || idx+1 >= len(title) {
return 0
}
pos := 0
for i := idx + 1; i < len(title); i++ {
if title[i] < '0' || title[i] > '9' {
break
}
pos = pos*10 + int(title[i]-'0')
}
return pos
}