Skip to content

Commit 19e2fbf

Browse files
zhengkylclaude
andcommitted
feat: sound effects, animated intro, dashboard solver, port config
- Game: staggered pop animation with Web Audio API sound, eased slide-in intro with day/date overlay, duplicate word rejection, prevMatchedCount for green tile transition - PageLayout: mute/unmute toggle, logo links home - Results: ScoreDistribution promoted above moves list (now collapsible) - Dashboard: embed top5000.txt word list, recursive puzzle solver with histogram view after save, input validation - Backend: duplicate word detection in results handler, PORT env var (default 2704) - Infra: PORT configurable across Dockerfile, Makefile, compose.yaml, vite proxy - Remove TutorialPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 492725a commit 19e2fbf

25 files changed

Lines changed: 5608 additions & 305 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
- UnoCSS: ALWAYS group variants e.g. `md:(one two three)`
55
- NO optional chaining (`?.`). Either the value is never null (access directly) or the type needs redesign (make impossible states unrepresentable)
66
- Do not code defensively. Do not let bad inputs gracefully fail. Assert/enforce validity at the type level or throw
7+
- No IIFEs unless absolutely necessary. Use a named `const` or extract a function instead.
78
- Do not run build or type checking commands. The user has a dev server running and can check manually.
89
- The user may make changes to the code as you work. If the change is not simple formatting, assume it is an intentional change by the user.

Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ WORKDIR /app
2020
COPY --from=go-builder /bin/server /bin/server
2121
COPY --from=go-builder /bin/dashboard /bin/dashboard
2222
COPY --from=client-builder /app/dist ./client/dist
23-
EXPOSE 3000
23+
ARG PORT=2704
24+
ENV PORT=$PORT
25+
EXPOSE $PORT
2426
ENV ROOT=/app
2527
CMD ["/bin/server"]

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
.PHONY: dev dev-dash build install start start-dash addword addwords
22

3+
DEV_PORT ?= 3000
4+
PORT ?= 2704
5+
36
install:
47
cd client && pnpm install
58
cd backend && go mod download
69

710
dev:
8-
trap 'kill %1' EXIT; cd client && pnpm dev & cd backend && go run .
11+
trap 'kill %1' EXIT; cd client && PORT=$(PORT) pnpm dev --port $(DEV_PORT) --host & cd backend && PORT=$(PORT) go run .
912

1013
dev-dash:
1114
cd backend && go run cmd/dashboard/dashboard.go

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919

2020
`dictionary.txt` is based on the [Letterpress word list](https://github.com/lorenbrichter/Words) (CC0). Changes after 7a08f2f are my own.
2121

22+
`top5000.txt` is from [www.wordfrequency.info](https://www.wordfrequency.info). Top lemmas from the Corpus of Contemporary American English (COCA)
23+
2224
### Other word lists
2325

2426
- https://github.com/dwyl/english-words
@@ -28,7 +30,7 @@
2830

2931
## Development
3032

31-
Make sure to access development site via vite's port (probably localhost:5173). Api requests are proxied by vite to port 3000 (hardcoded in vite.config.ts and main.go)
33+
Make sure to access development site via vite's port (probably localhost:5173). Api requests are proxied by vite to a different port.
3234

3335
```sh
3436
make install
@@ -39,7 +41,7 @@ make dev
3941

4042
## Deploy
4143

42-
The server listens at `localhost:3000`. It expects the `X-Real-IP` header for rate-limiting.
44+
The server listens at `localhost:2704`. It expects the `X-Real-IP` header for rate-limiting.
4345

4446
### Docker Compose
4547

backend/cmd/dashboard/dashboard.go

Lines changed: 194 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,47 @@
11
package main
22

33
import (
4+
_ "embed"
45
"database/sql"
6+
"errors"
57
"fmt"
68
"os"
79
"path/filepath"
810
"strconv"
11+
"strings"
912
"time"
13+
"unicode"
1014

1115
"github.com/charmbracelet/bubbles/textinput"
1216
tea "github.com/charmbracelet/bubbletea"
1317
"github.com/charmbracelet/lipgloss"
1418
_ "github.com/mattn/go-sqlite3"
1519
)
1620

21+
//go:embed top5000.txt
22+
var dictFile string
23+
24+
var dict [][]rune
25+
26+
func init() {
27+
for _, line := range strings.Split(strings.TrimSpace(dictFile), "\n") {
28+
word := strings.TrimSpace(line)
29+
if word == "" {
30+
continue
31+
}
32+
valid := true
33+
for _, r := range word {
34+
if !unicode.IsLetter(r) || !unicode.IsLower(r) {
35+
valid = false
36+
break
37+
}
38+
}
39+
if valid {
40+
dict = append(dict, []rune(word))
41+
}
42+
}
43+
}
44+
1745
var epoch = time.Date(2026, time.April, 26, 0, 0, 0, 0, time.UTC)
1846

1947
func dayToDate(day int) time.Time {
@@ -30,20 +58,24 @@ type viewState int
3058
const (
3159
viewList viewState = iota
3260
viewEdit
61+
viewResult
3362
)
3463

3564
type model struct {
36-
db *sql.DB
37-
puzzles []puzzle
38-
cursor int
39-
view viewState
40-
input textinput.Model
41-
editDay int
42-
err error
65+
db *sql.DB
66+
puzzles []puzzle
67+
cursor int
68+
view viewState
69+
input textinput.Model
70+
editDay int
71+
err error
72+
resultPuzzle string
73+
histogram *[maxWords + 1]int
4374
}
4475

4576
type puzzlesMsg []puzzle
46-
type savedMsg struct{}
77+
type savedMsg struct{ puzzle string }
78+
type histogramMsg [maxWords + 1]int
4779
type errMsg error
4880

4981
var (
@@ -82,14 +114,94 @@ func savePuzzle(db *sql.DB, day int, pz string) tea.Cmd {
82114
if err != nil {
83115
return errMsg(err)
84116
}
85-
return savedMsg{}
117+
return savedMsg{puzzle: pz}
118+
}
119+
}
120+
121+
// matchedCount returns how many tiles from the front of puzzle are covered by word.
122+
func matchedCount(puzzle, word []rune) int {
123+
var used []int
124+
for _, pc := range puzzle {
125+
found := false
126+
for wi, wc := range word {
127+
if wc != pc {
128+
continue
129+
}
130+
inUsed := false
131+
for _, u := range used {
132+
if u == wi {
133+
inUsed = true
134+
break
135+
}
136+
}
137+
if inUsed {
138+
continue
139+
}
140+
used = append(used, wi)
141+
found = true
142+
break
143+
}
144+
if !found {
145+
break
146+
}
147+
}
148+
return len(used)
149+
}
150+
151+
const (
152+
maxWords = 13
153+
maxHistCount = 1_000_000
154+
)
155+
156+
// solve returns hist where hist[n] = # of ways to complete remaining in exactly n words (1–13).
157+
func solve(remaining []rune, memo map[string][maxWords + 1]int) [maxWords + 1]int {
158+
if len(remaining) == 0 {
159+
return [maxWords + 1]int{1}
160+
}
161+
key := string(remaining)
162+
if cached, ok := memo[key]; ok {
163+
return cached
164+
}
165+
var result [maxWords + 1]int
166+
for _, word := range dict {
167+
n := matchedCount(remaining, word)
168+
if n < 2 {
169+
continue
170+
}
171+
sub := solve(remaining[n:], memo)
172+
for i := 0; i < maxWords; i++ {
173+
result[i+1] += sub[i]
174+
if result[i+1] > maxHistCount {
175+
result[i+1] = maxHistCount
176+
}
177+
}
178+
}
179+
memo[key] = result
180+
return result
181+
}
182+
183+
func computeHistogram(pz string) tea.Cmd {
184+
return func() tea.Msg {
185+
memo := map[string][maxWords + 1]int{}
186+
hist := solve([]rune(pz), memo)
187+
return histogramMsg(hist)
86188
}
87189
}
88190

191+
func onlyLowerAlpha(s string) error {
192+
for _, r := range s {
193+
if !unicode.IsLetter(r) || !unicode.IsLower(r) {
194+
return errors.New("only lowercase letters allowed")
195+
}
196+
}
197+
return nil
198+
}
199+
89200
func newModel(db *sql.DB) model {
90201
ti := textinput.New()
91202
ti.Placeholder = "puzzle string"
92203
ti.CharLimit = 256
204+
ti.Validate = onlyLowerAlpha
93205

94206
return model{db: db, view: viewList, input: ti, editDay: -1}
95207
}
@@ -101,17 +213,25 @@ func (m model) Init() tea.Cmd {
101213
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
102214
switch msg := msg.(type) {
103215
case tea.KeyMsg:
104-
if m.view == viewList {
216+
switch m.view {
217+
case viewList:
105218
return m.updateList(msg)
219+
case viewEdit:
220+
return m.updateEdit(msg)
221+
case viewResult:
222+
return m.updateResult(msg)
106223
}
107-
return m.updateEdit(msg)
108224
case puzzlesMsg:
109225
m.puzzles = []puzzle(msg)
110226
m.cursor = min(m.cursor, max(0, len(m.puzzles)-1))
111227
m.err = nil
112228
return m, nil
113229
case savedMsg:
114-
return m, loadPuzzles(m.db)
230+
return m, tea.Batch(computeHistogram(msg.puzzle), loadPuzzles(m.db))
231+
case histogramMsg:
232+
h := [maxWords + 1]int(msg)
233+
m.histogram = &h
234+
return m, nil
115235
case errMsg:
116236
m.err = error(msg)
117237
return m, nil
@@ -162,7 +282,9 @@ func (m model) updateEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
162282
if val == "" {
163283
return m, nil
164284
}
165-
m.view = viewList
285+
m.view = viewResult
286+
m.resultPuzzle = val
287+
m.histogram = nil
166288
m.input.Blur()
167289
return m, savePuzzle(m.db, m.editDay, val)
168290
}
@@ -171,9 +293,20 @@ func (m model) updateEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
171293
return m, cmd
172294
}
173295

296+
func (m model) updateResult(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
297+
if msg.String() == "ctrl+c" {
298+
return m, tea.Quit
299+
}
300+
m.view = viewList
301+
return m, nil
302+
}
303+
174304
func (m model) View() string {
175-
if m.view == viewEdit {
305+
switch m.view {
306+
case viewEdit:
176307
return m.viewEdit()
308+
case viewResult:
309+
return m.viewResult()
177310
}
178311
return m.viewList()
179312
}
@@ -213,7 +346,8 @@ func (m model) viewEdit() string {
213346
}
214347

215348
s := titleStyle.Render(title) + "\n\n"
216-
s += "Puzzle: " + m.input.View() + "\n\n"
349+
s += "Puzzle: " + m.input.View() + "\n"
350+
s += dimStyle.Render(fmt.Sprintf("%d chars", len([]rune(m.input.Value())))) + "\n\n"
217351
s += dimStyle.Render("[enter] save [esc] cancel")
218352

219353
if m.err != nil {
@@ -223,6 +357,51 @@ func (m model) viewEdit() string {
223357
return s
224358
}
225359

360+
func (m model) viewResult() string {
361+
var dayStr string
362+
if m.editDay == -1 {
363+
dayStr = "new"
364+
} else {
365+
date := dayToDate(m.editDay)
366+
dayStr = fmt.Sprintf("Day %d, %s", m.editDay, date.Format("Jan 02"))
367+
}
368+
s := titleStyle.Render(fmt.Sprintf("%q (%s)", m.resultPuzzle, dayStr)) + "\n\n"
369+
370+
s += dimStyle.Render(fmt.Sprintf("dict: %d words", len(dict))) + "\n\n"
371+
372+
if m.histogram == nil {
373+
s += dimStyle.Render("Computing...") + "\n"
374+
} else {
375+
hist := m.histogram
376+
377+
maxVal := 0
378+
for i := 1; i <= maxWords; i++ {
379+
if hist[i] > maxVal {
380+
maxVal = hist[i]
381+
}
382+
}
383+
384+
const barWidth = 20
385+
for i := 1; i <= maxWords; i++ {
386+
label := fmt.Sprintf("%2d word", i)
387+
if i != 1 {
388+
label += "s"
389+
} else {
390+
label += " "
391+
}
392+
filled := 0
393+
if maxVal > 0 {
394+
filled = max(0, min(barWidth, hist[i]*barWidth/maxVal))
395+
}
396+
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
397+
s += fmt.Sprintf("%s %s %d\n", label, bar, hist[i])
398+
}
399+
}
400+
401+
s += "\n" + dimStyle.Render("[any key] back")
402+
return s
403+
}
404+
226405
func main() {
227406
root, ok := os.LookupEnv("ROOT")
228407
if !ok {

0 commit comments

Comments
 (0)