server.go
9987 bytes
1package main
2
3import (
4 "embed"
5 "fmt"
6 "html/template"
7 "io/fs"
8 "net/http"
9 "strconv"
10 "strings"
11 "sync"
12)
13
14//go:embed web/templates/*.html
15var templateFS embed.FS
16
17//go:embed web/static/*
18var staticFS embed.FS
19
20// Game holds the mutable state of a single-player game session. All access is
21// guarded by mu because HTTP handlers run concurrently.
22type Game struct {
23 mu sync.Mutex
24 catalog []Song
25 order []int
26 pos int
27 rounds int
28 played int
29 nonce int
30 score int
31 answer Song
32 startSec int
33 segment []byte // WAV bytes for the current round's clip
34 extendedSegment []byte // extended hint clip, generated on demand
35 hinted bool
36 done bool
37 result *RoundResult
38}
39
40// RoundResult captures the outcome of a submitted guess, ready for rendering.
41type RoundResult struct {
42 Points int
43 Parts []string
44 Guesses []string
45 FieldCorrect []bool
46 FieldPartial []bool
47 ResultMsg string
48 Hinted bool
49}
50
51// fieldResult describes one scored field for the result screen, mirroring the
52// TUI layout: a dim label, an optional strikethrough guess, and the answer.
53type fieldResult struct {
54 Label string
55 Guess string
56 Answer string
57 State string // correct, empty, partial, wrong
58}
59
60func newGame(catalog []Song, rounds int) *Game {
61 g := &Game{
62 catalog: catalog,
63 rounds: rounds,
64 order: shuffled(len(catalog)),
65 }
66 g.beginRound()
67 return g
68}
69
70// beginRound selects the next song and resets the per-round state. Audio
71// generation is wired in by a later stage.
72func (g *Game) beginRound() {
73 g.answer = g.nextSong()
74 g.nonce++
75 g.hinted = false
76 g.result = nil
77 g.startSec = -1
78 g.segment = nil
79 g.extendedSegment = nil
80 start, err := pickStart(g.answer.Path)
81 if err != nil {
82 return
83 }
84 g.startSec = start
85 wav, werr := generateSegment(g.answer.Path, start, defaultPlay)
86 if werr != nil {
87 return
88 }
89 g.segment = wav
90}
91
92func (g *Game) nextSong() Song {
93 if g.pos >= len(g.order) {
94 g.pos = 0
95 g.order = shuffled(len(g.catalog))
96 }
97 idx := g.order[g.pos]
98 g.pos++
99 return g.catalog[idx]
100}
101
102// submitGuess scores the player's guess against the current answer and stores
103// the result for rendering.
104func (g *Game) submitGuess(title, album, artist string) {
105 guesses := []string{title, album, artist}
106 points := 0
107 var parts []string
108 fieldCorrect := make([]bool, len(fields))
109 fieldPartial := make([]bool, len(fields))
110 for i, f := range fields {
111 v := guesses[i]
112 if v != "" {
113 sim := fuzzyScore(v, f.value(g.answer))
114 points += scoreFor(sim, f.weight)
115 switch {
116 case sim >= matchThreshold:
117 fieldCorrect[i] = true
118 parts = append(parts, f.label)
119 case sim >= lowerThreshold:
120 fieldPartial[i] = true
121 }
122 }
123 }
124 if g.hinted {
125 points /= 2
126 }
127 g.score += points
128 g.played++
129 g.result = &RoundResult{
130 Points: points,
131 Parts: parts,
132 Guesses: guesses,
133 FieldCorrect: fieldCorrect,
134 FieldPartial: fieldPartial,
135 ResultMsg: resultMessage(points, parts),
136 Hinted: g.hinted,
137 }
138 if g.played >= g.rounds {
139 g.done = true
140 }
141}
142
143// resultMessage builds the human-readable summary line for a scored guess.
144func resultMessage(points int, parts []string) string {
145 switch {
146 case len(parts) == 3:
147 return fmt.Sprintf("Correct! +%d %s", points, pointWord(points))
148 case len(parts) > 0:
149 return fmt.Sprintf("%s correct! +%d %s", strings.Join(parts, ", "), points, pointWord(points))
150 case points > 0:
151 return fmt.Sprintf("+%d %s", points, pointWord(points))
152 default:
153 return "Wrong."
154 }
155}
156
157// nextRound advances to the next song (generating its audio clip), or is a
158// no-op when the game is finished.
159func (g *Game) nextRound() {
160 if g.done {
161 return
162 }
163 g.beginRound()
164}
165
166// restart begins a fresh game with a new song order and zero score.
167func (g *Game) restart() {
168 g.order = shuffled(len(g.catalog))
169 g.pos = 0
170 g.played = 0
171 g.score = 0
172 g.done = false
173 g.beginRound()
174}
175
176// viewData is the render model passed to the templates.
177type viewData struct {
178 Score int
179 MaxScore int
180 Round int
181 TotalRounds int
182 Dots []bool
183 Nonce int
184 Answer Song
185 Hinted bool
186 Done bool
187 Result *RoundResult
188 Fields []fieldResult
189 ResultMark string
190 ResultTone string
191}
192
193func (g *Game) viewData() viewData {
194 round := g.played + 1
195 if round > g.rounds {
196 round = g.rounds
197 }
198 dots := make([]bool, g.rounds)
199 for i := 0; i < g.played && i < g.rounds; i++ {
200 dots[i] = true
201 }
202 mark, tone := "", ""
203 if g.result != nil && !g.done {
204 switch {
205 case len(g.result.Parts) == 0 && g.result.Points == 0:
206 mark, tone = "✘", "red"
207 case g.hinted:
208 mark, tone = "✔", "orange"
209 case len(g.result.Parts) == 3:
210 mark, tone = "✔", "green"
211 default:
212 mark, tone = "✔", "yellow"
213 }
214 }
215 return viewData{
216 Score: g.score,
217 MaxScore: g.rounds * pointsPerRound,
218 Round: round,
219 TotalRounds: g.rounds,
220 Dots: dots,
221 Nonce: g.nonce,
222 Answer: g.answer,
223 Hinted: g.hinted,
224 Done: g.done,
225 Result: g.result,
226 Fields: g.fieldResults(),
227 ResultMark: mark,
228 ResultTone: tone,
229 }
230}
231
232// fieldResults builds the per-field descriptors for the result screen.
233func (g *Game) fieldResults() []fieldResult {
234 out := make([]fieldResult, len(fields))
235 for i, f := range fields {
236 guess := ""
237 state := "wrong"
238 if g.result != nil {
239 guess = g.result.Guesses[i]
240 switch {
241 case g.result.FieldCorrect[i]:
242 state = "correct"
243 case guess == "":
244 state = "empty"
245 case g.result.FieldPartial[i]:
246 state = "partial"
247 }
248 }
249 out[i] = fieldResult{Label: f.label, Guess: guess, Answer: f.value(g.answer), State: state}
250 }
251 return out
252}
253
254// server bundles the game session with its parsed templates.
255type server struct {
256 game *Game
257 templates *template.Template
258}
259
260func newServer(catalog []Song, rounds int) (*server, error) {
261 tpl, err := template.ParseFS(templateFS, "web/templates/*.html")
262 if err != nil {
263 return nil, err
264 }
265 return &server{game: newGame(catalog, rounds), templates: tpl}, nil
266}
267
268func (s *server) handler() http.Handler {
269 mux := http.NewServeMux()
270 staticSub, _ := fs.Sub(staticFS, "web/static")
271 mux.HandleFunc("/", s.handleHome)
272 mux.HandleFunc("/audio", s.handleAudio)
273 mux.HandleFunc("/audio/long", s.handleAudioLong)
274 mux.HandleFunc("/guess", s.handleGuess)
275 mux.HandleFunc("/next", s.handleNext)
276 mux.HandleFunc("/restart", s.handleRestart)
277 mux.Handle("/static/", stripPrefix("/static/", http.FileServer(http.FS(staticSub))))
278 return mux
279}
280
281// stripPrefix rewrites the request path so a handler mounted at prefix sees
282// paths relative to that prefix (http.FileServer does not do this itself).
283func stripPrefix(prefix string, h http.Handler) http.Handler {
284 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285 r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
286 h.ServeHTTP(w, r)
287 })
288}
289
290func (s *server) handleHome(w http.ResponseWriter, r *http.Request) {
291 if r.URL.Path != "/" {
292 http.NotFound(w, r)
293 return
294 }
295 s.game.mu.Lock()
296 data := s.game.viewData()
297 s.game.mu.Unlock()
298
299 w.Header().Set("Content-Type", "text/html; charset=utf-8")
300 if err := s.templates.ExecuteTemplate(w, "page", data); err != nil {
301 http.Error(w, err.Error(), http.StatusInternalServerError)
302 }
303}
304
305func (s *server) handleAudio(w http.ResponseWriter, r *http.Request) {
306 s.game.mu.Lock()
307 seg := s.game.segment
308 s.game.mu.Unlock()
309 if len(seg) == 0 {
310 http.NotFound(w, r)
311 return
312 }
313 w.Header().Set("Content-Type", "audio/wav")
314 w.Header().Set("Cache-Control", "no-store")
315 w.Header().Set("Content-Length", strconv.Itoa(len(seg)))
316 if _, err := w.Write(seg); err != nil {
317 return
318 }
319}
320
321// handleAudioLong serves the extended (extendedPlay s) hint clip from the same
322// start point. Requesting it marks the round as hinted, which halves that
323// round's points — matching the TUI's Ctrl+L behaviour. Generated on first
324// request and cached for the round.
325func (s *server) handleAudioLong(w http.ResponseWriter, r *http.Request) {
326 s.game.mu.Lock()
327 defer s.game.mu.Unlock()
328 if s.game.startSec < 0 {
329 http.NotFound(w, r)
330 return
331 }
332 s.game.hinted = true
333 if len(s.game.extendedSegment) == 0 {
334 wav, err := generateSegment(s.game.answer.Path, s.game.startSec, extendedPlay)
335 if err != nil {
336 http.Error(w, "audio generation failed", http.StatusInternalServerError)
337 return
338 }
339 s.game.extendedSegment = wav
340 }
341 seg := s.game.extendedSegment
342 w.Header().Set("Content-Type", "audio/wav")
343 w.Header().Set("Cache-Control", "no-store")
344 w.Header().Set("Content-Length", strconv.Itoa(len(seg)))
345 if _, err := w.Write(seg); err != nil {
346 return
347 }
348}
349
350// renderPartial writes the "game" partial (HUD + current state) to w. Every
351// htmx response uses this so the whole game area refreshes consistently.
352func renderPartial(w http.ResponseWriter, tpl *template.Template, data viewData) {
353 w.Header().Set("Content-Type", "text/html; charset=utf-8")
354 if err := tpl.ExecuteTemplate(w, "game", data); err != nil {
355 http.Error(w, err.Error(), http.StatusInternalServerError)
356 }
357}
358
359func (s *server) handleGuess(w http.ResponseWriter, r *http.Request) {
360 if r.Method != http.MethodPost {
361 http.NotFound(w, r)
362 return
363 }
364 title := r.FormValue("title")
365 album := r.FormValue("album")
366 artist := r.FormValue("artist")
367
368 s.game.mu.Lock()
369 s.game.submitGuess(title, album, artist)
370 data := s.game.viewData()
371 s.game.mu.Unlock()
372 renderPartial(w, s.templates, data)
373}
374
375func (s *server) handleNext(w http.ResponseWriter, r *http.Request) {
376 s.game.mu.Lock()
377 s.game.nextRound()
378 data := s.game.viewData()
379 s.game.mu.Unlock()
380 renderPartial(w, s.templates, data)
381}
382
383func (s *server) handleRestart(w http.ResponseWriter, r *http.Request) {
384 if r.Method != http.MethodPost {
385 http.NotFound(w, r)
386 return
387 }
388 s.game.mu.Lock()
389 s.game.restart()
390 data := s.game.viewData()
391 s.game.mu.Unlock()
392 renderPartial(w, s.templates, data)
393}