mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
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>
This commit is contained in:
@@ -38,6 +38,7 @@ type Lap = models.Lap
|
||||
type Chapter struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Headline string `json:"headline"`
|
||||
StartLap int `json:"start_lap"`
|
||||
EndLap int `json:"end_lap"`
|
||||
StartTime string `json:"start_time,omitempty"`
|
||||
|
||||
233
internal/chapters/headlines.go
Normal file
233
internal/chapters/headlines.go
Normal file
@@ -0,0 +1,233 @@
|
||||
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%d–L%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%d–L%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%d–L%d", names[0], names[1], ch.StartLap, ch.EndLap),
|
||||
fmt.Sprintf("Undercut window opens — %s leads the pit rush on L%d–L%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%d–L%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
|
||||
}
|
||||
199
internal/chapters/headlines_test.go
Normal file
199
internal/chapters/headlines_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package chapters
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
func TestHeadlineStart(t *testing.T) {
|
||||
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
want := "Race start — Lap 1 shuffle at the front"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineStartVariantByLap(t *testing.T) {
|
||||
ch := Chapter{Kind: KindStart, Title: "Start", StartLap: 2, EndLap: 2}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
want := "Lights out — the field charges into Turn 1"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineSafetyCarWithDriver(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
||||
})
|
||||
rc := []RaceControl{
|
||||
{
|
||||
DriverNumber: intPtr(55),
|
||||
LapNumber: intPtr(12),
|
||||
Message: "CAR 55 STOPPED ON TRACK",
|
||||
},
|
||||
rc(12, models.CategorySafetyCar, "", "SAFETY CAR DEPLOYED"),
|
||||
rc(15, models.CategorySafetyCar, "", "SAFETY CAR IN THIS LAP"),
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindSafetyCar,
|
||||
Title: "Safety Car (L12-L15)",
|
||||
StartLap: 12,
|
||||
EndLap: 15,
|
||||
}
|
||||
got := headlineFor(ch, drivers, rc, 0)
|
||||
want := "Sainz incident brings out the Safety Car — leaders dive for the pits"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineSafetyCarFallbackWithoutDriver(t *testing.T) {
|
||||
ch := Chapter{
|
||||
Kind: KindSafetyCar,
|
||||
Title: "Safety Car (L12-L15)",
|
||||
StartLap: 12,
|
||||
EndLap: 15,
|
||||
}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
if got != ch.Title {
|
||||
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineVirtualSafetyCar(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||
})
|
||||
rc := []RaceControl{
|
||||
{
|
||||
DriverNumber: intPtr(16),
|
||||
LapNumber: intPtr(22),
|
||||
Message: "CAR 16 OFF TRACK",
|
||||
},
|
||||
rc(22, models.CategoryOther, "", "VSC DEPLOYED"),
|
||||
rc(24, models.CategoryOther, "", "VSC ENDING"),
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindVirtualSafetyCar,
|
||||
Title: "Virtual Safety Car (L22-L24)",
|
||||
StartLap: 22,
|
||||
EndLap: 24,
|
||||
}
|
||||
got := headlineFor(ch, drivers, rc, 0)
|
||||
want := "Leclerc off track triggers the Virtual Safety Car"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineRedFlag(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 63, NameAcronym: "RUS", FullName: "George Russell", TeamName: "Mercedes"},
|
||||
})
|
||||
rc := []RaceControl{
|
||||
{
|
||||
DriverNumber: intPtr(63),
|
||||
LapNumber: intPtr(31),
|
||||
Message: "INCIDENT INVOLVING CAR 63",
|
||||
},
|
||||
rc(31, models.CategoryFlag, models.FlagRed, "RED FLAG"),
|
||||
rc(33, models.CategoryFlag, models.FlagGreen, "GREEN FLAG"),
|
||||
}
|
||||
ch := Chapter{
|
||||
Kind: KindRedFlag,
|
||||
Title: "Red Flag (L31-L33)",
|
||||
StartLap: 31,
|
||||
EndLap: 33,
|
||||
}
|
||||
got := headlineFor(ch, drivers, rc, 0)
|
||||
want := "Red flag after Russell incident on Lap 31"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlinePitPhase(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
||||
{DriverNumber: 44, NameAcronym: "HAM", FullName: "Lewis Hamilton", TeamName: "Mercedes"},
|
||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||
})
|
||||
ch := Chapter{
|
||||
Kind: KindPitPhase,
|
||||
Title: "Pit phase (L20-L22)",
|
||||
StartLap: 20,
|
||||
EndLap: 22,
|
||||
DriverNumbers: []int{1, 44, 16},
|
||||
}
|
||||
got := headlineFor(ch, drivers, nil, 0)
|
||||
want := "Mass pit-window scramble — Verstappen and Hamilton box on L20–L22"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineDecisiveSwing(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 16, NameAcronym: "LEC", FullName: "Charles Leclerc", TeamName: "Ferrari"},
|
||||
{DriverNumber: 55, NameAcronym: "SAI", FullName: "Carlos Sainz", TeamName: "Ferrari"},
|
||||
})
|
||||
ch := Chapter{
|
||||
Kind: KindDecisiveSwing,
|
||||
Title: "Decisive swing: #16 to P3 (L6)",
|
||||
StartLap: 6,
|
||||
EndLap: 6,
|
||||
DriverNumbers: []int{16, 55},
|
||||
}
|
||||
got := headlineFor(ch, drivers, nil, 0)
|
||||
want := "Leclerc overtakes Sainz for P3"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineFinish(t *testing.T) {
|
||||
drivers := BuildDriverMap(nil, []DriverIdentityInput{
|
||||
{DriverNumber: 1, NameAcronym: "VER", FullName: "Max Verstappen", TeamName: "Red Bull"},
|
||||
})
|
||||
ch := Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: "Finish (L57-L58)",
|
||||
StartLap: 57,
|
||||
EndLap: 58,
|
||||
}
|
||||
got := headlineFor(ch, drivers, nil, 1)
|
||||
want := "Chequered flag — Verstappen wins the race"
|
||||
if got != want {
|
||||
t.Fatalf("headline = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineFinishFallbackWithoutWinner(t *testing.T) {
|
||||
ch := Chapter{
|
||||
Kind: KindFinish,
|
||||
Title: "Finish (L57-L58)",
|
||||
StartLap: 57,
|
||||
EndLap: 58,
|
||||
}
|
||||
got := headlineFor(ch, nil, nil, 0)
|
||||
if got != ch.Title {
|
||||
t.Fatalf("headline = %q, want fallback %q", got, ch.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHeadlinesPreservesChapterFields(t *testing.T) {
|
||||
input := []Chapter{
|
||||
{Kind: KindStart, Title: "Start", StartLap: 1, EndLap: 1},
|
||||
}
|
||||
got := ApplyHeadlines(input, nil, nil, 0)
|
||||
if len(got) != 1 || got[0].StartLap != 1 || got[0].Headline == "" {
|
||||
t.Fatalf("ApplyHeadlines = %+v, want headline on start chapter", got)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
@@ -252,10 +252,38 @@ func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
}
|
||||
|
||||
hub.Chapters = chapters.Detect(hub.RaceControl, hub.Positions, hub.Laps, totalLaps(hub.Results, hub.Laps))
|
||||
hub.Chapters = chapters.ApplyHeadlines(
|
||||
hub.Chapters,
|
||||
chapters.BuildDriverMap(hub.Drivers, driverIdentityInputs(hub.Results)),
|
||||
hub.RaceControl,
|
||||
winnerDriverNumber(hub.Results),
|
||||
)
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
func driverIdentityInputs(results []EnrichedResult) []chapters.DriverIdentityInput {
|
||||
out := make([]chapters.DriverIdentityInput, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, chapters.DriverIdentityInput{
|
||||
DriverNumber: r.DriverNumber,
|
||||
NameAcronym: r.NameAcronym,
|
||||
FullName: r.FullName,
|
||||
TeamName: r.TeamName,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func winnerDriverNumber(results []EnrichedResult) int {
|
||||
for _, r := range results {
|
||||
if r.Position == 1 {
|
||||
return r.DriverNumber
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func totalLaps(results []EnrichedResult, laps []models.Lap) int {
|
||||
total := 0
|
||||
for _, result := range results {
|
||||
|
||||
Reference in New Issue
Block a user