Parent directory

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}