clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

tech debt 5

Dami committed 85d46b9
commit
85d46b9064fd5ff121126b0384b0cbf8ad1adab0
parent
4242efd6cc85182bcfbe9f8cf55579309e61f575
author
Dami <dami.etoile@gmail.com>
45 files changed, +367 -850
Mapp/handle_daily.go2+
Mapp/handle_game.go7+-
Mapp/handle_help.go2+-
Mapp/handle_menu.go8+-
Mapp/handle_seed.go4+-
Mapp/model.go35+-
Mapp/seed_input.go52++--
Mapp/spawn.go1+
Mapp/state_update.go18+-
Mapp/state_view.go8+-
Mapp/update_test.go40+-
Mapp/view.go6+-
Mapp/weekly.go35+-
Marchitecture_test.go64++++
Mbuiltinprint/register.go36+--
Mbuiltinprint/register_test.go27+-
Mcmd/export_pdf_test.go10+-
Mcmd/new.go4+-
Mcmd/new_export.go2+-
Mcmd/new_export_test.go7+-
Mfillomino/gamemode.go1+
Mgamereg/gamereg.go3+
Mgamereg/gamereg_test.go3+
Mhashiwokakero/gamemode.go1+
Mhitori/gamemode.go1+
Mnonogram/gamemode.go1+
Mnurikabe/gamemode.go1+
Mpdfexport/jsonl.go15+
Mpdfexport/jsonl_test.go63++--
Dpdfexport/parse.go488------------------------------
Dpdfexport/parse_handlers.go31--
Dpdfexport/parse_test.go134--------
Mregistry/registry_test.go14+
Mrippleeffect/gamemode.go1+
Msession/session.go15+-
Msession/session_test.go27++-
Mshikaku/gamemode.go1+
Mspellpuzzle/gamemode.go1+
Mstore/game_record.go9+
Mstore/metadata.go34++-
Msudoku/gamemode.go1+
Msudokurgb/gamemode.go1+
Mtakuzu/gamemode.go1+
Mtakuzuplus/gamemode.go1+
Mwordsearch/gamemode.go1+
M app/handle_daily.go +2 -0
M app/handle_game.go +4 -3
M app/handle_help.go +1 -1
M app/handle_menu.go +4 -4
M app/handle_seed.go +3 -1
M app/model.go +24 -11
@@ -71,17 +71,25 @@ type navigationState struct {
7171 modeSelectList list.Model7272 selectedCategory registry.Entry7373 selectedModeTitle string74- continueTable table.Model75- continueGames []store.GameRecord76- weeklyTable table.Model77- weeklyRows []weeklyRow78- weeklyCursor time.Time79- seedInput textinput.Model80- seedModeOptions []seedModeOption81- seedModeIndex int82- seedFocus seedInputFocus83- lastSeedModeKey string84- helpSelectList list.Model74+}75+76+type continueState struct {77+ table table.Model78+ games []store.GameRecord79+}80+81+type weeklyState struct {82+ table table.Model83+ rows []weeklyRow84+ cursor time.Time85+}86+87+type seedState struct {88+ input textinput.Model89+ modeOptions []seedModeOption90+ modeIndex int91+ focus seedInputFocus92+ lastModeKey string8593 }8694 8795 type sessionState struct {
@@ -97,6 +105,7 @@ type sessionState struct {
97105 }98106 99107 type helpState struct {108+ selectList list.Model100109 category registry.Entry101110 viewport viewport.Model102111 renderer *glamour.TermRenderer
@@ -136,6 +145,7 @@ type spawnRequest struct {
136145 name string137146 gameType string138147 modeTitle string148+ run store.RunMetadata139149 returnState viewState140150 exitState viewState141151 weeklyInfo *weekly.Info
@@ -158,7 +168,10 @@ type model struct {
158168 state viewState159169 160170 nav navigationState171+ cont continueState161172 session sessionState173+ seed seedState174+ weekly weeklyState162175 help helpState163176 stats statsState164177 theme themeState
M app/seed_input.go +26 -26
@@ -75,46 +75,46 @@ func (m model) enterSeedInputView() (model, tea.Cmd) {
7575 ti.SetWidth(min(m.width, 48))7676 7777 options := buildSeedModeOptions(registry.Definitions())78- index := findSeedModeIndex(options, m.nav.lastSeedModeKey)78+ index := findSeedModeIndex(options, m.seed.lastModeKey)7979 80- m.nav.seedInput = ti81- m.nav.seedModeOptions = options82- m.nav.seedModeIndex = index83- m.nav.seedFocus = seedFocusText80+ m.seed.input = ti81+ m.seed.modeOptions = options82+ m.seed.modeIndex = index83+ m.seed.focus = seedFocusText8484 m.state = seedInputView85- return m, m.nav.seedInput.Focus()85+ return m, m.seed.input.Focus()8686 }8787 8888 func (m model) currentSeedMode() seedModeOption {89- if len(m.nav.seedModeOptions) == 0 {89+ if len(m.seed.modeOptions) == 0 {9090 return seedModeOption{label: randomSeedModeLabel}9191 }92- if m.nav.seedModeIndex < 0 || m.nav.seedModeIndex >= len(m.nav.seedModeOptions) {93- return m.nav.seedModeOptions[0]92+ if m.seed.modeIndex < 0 || m.seed.modeIndex >= len(m.seed.modeOptions) {93+ return m.seed.modeOptions[0]9494 }95- return m.nav.seedModeOptions[m.nav.seedModeIndex]95+ return m.seed.modeOptions[m.seed.modeIndex]9696 }9797 9898 func (m model) moveSeedMode(step int) model {99- if len(m.nav.seedModeOptions) == 0 {99+ if len(m.seed.modeOptions) == 0 {100100 return m101101 }102102 103- index := m.nav.seedModeIndex + step103+ index := m.seed.modeIndex + step104104 for index < 0 {105- index += len(m.nav.seedModeOptions)105+ index += len(m.seed.modeOptions)106106 }107- m.nav.seedModeIndex = index % len(m.nav.seedModeOptions)108- m.nav.lastSeedModeKey = m.currentSeedMode().key107+ m.seed.modeIndex = index % len(m.seed.modeOptions)108+ m.seed.lastModeKey = m.currentSeedMode().key109109 return m110110 }111111 112112 func (m model) setSeedFocus(focus seedInputFocus) (model, tea.Cmd) {113- m.nav.seedFocus = focus113+ m.seed.focus = focus114114 if focus == seedFocusText {115- return m, m.nav.seedInput.Focus()115+ return m, m.seed.input.Focus()116116 }117- m.nav.seedInput.Blur()117+ m.seed.input.Blur()118118 return m, nil119119 }120120
@@ -127,34 +127,34 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {
127127 case "down", "j":128128 return m.setSeedFocus(seedFocusMode)129129 case "left", "h":130- if m.nav.seedFocus == seedFocusMode {130+ if m.seed.focus == seedFocusMode {131131 return m.moveSeedMode(-1), nil132132 }133133 case "right", "l":134- if m.nav.seedFocus == seedFocusMode {134+ if m.seed.focus == seedFocusMode {135135 return m.moveSeedMode(1), nil136136 }137137 }138138 }139139 140- if m.nav.seedFocus != seedFocusText {140+ if m.seed.focus != seedFocusText {141141 return m, nil142142 }143143 144144 var cmd tea.Cmd145- m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)145+ m.seed.input, cmd = m.seed.input.Update(msg)146146 return m, cmd147147 }148148 149149 func (m model) seedInputBody() string {150- selector := renderSeedModeTitle(m.currentSeedMode().label, m.nav.seedModeIndex)150+ selector := renderSeedModeTitle(m.currentSeedMode().label, m.seed.modeIndex)151151 modePrefix := " "152- if m.nav.seedFocus == seedFocusMode {152+ if m.seed.focus == seedFocusMode {153153 modePrefix = ui.CursorStyle().Render("> ")154154 }155155 156- seedInputView := m.nav.seedInput.View()157- if m.nav.seedFocus != seedFocusText {156+ seedInputView := m.seed.input.View()157+ if m.seed.focus != seedFocusText {158158 seedInputView = strings.Replace(seedInputView, ">", " ", 1)159159 }160160
M app/spawn.go +1 -0
M app/state_update.go +9 -9
@@ -37,11 +37,11 @@ func (m model) updateActiveState(msg tea.Msg) (model, tea.Cmd) {
3737 case modeSelectView:3838 m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)3939 case continueView:40- m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)40+ m.cont.table, cmd = m.cont.table.Update(msg)4141 case weeklyView:42- m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)42+ m.weekly.table, cmd = m.weekly.table.Update(msg)4343 case helpSelectView:44- m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)44+ m.help.selectList, cmd = m.help.selectList.Update(msg)4545 case helpDetailView:4646 m.help.viewport, cmd = m.help.viewport.Update(msg)4747 case statsView:
@@ -63,18 +63,18 @@ func (m model) updateActiveState(msg tea.Msg) (model, tea.Cmd) {
6363 func (m model) resizeActiveState(menuW int) model {6464 switch m.state {6565 case seedInputView:66- m.nav.seedInput.SetWidth(min(m.width, 48))66+ m.seed.input.SetWidth(min(m.width, 48))6767 case modeSelectView:6868 m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList)))6969 case continueView:70- m.nav.continueTable.SetWidth(m.width)71- visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)72- m.nav.continueTable.SetHeight(min(m.height, visibleRows))70+ m.cont.table.SetWidth(m.width)71+ visibleRows := min(len(m.cont.games), ui.MaxTableRows)72+ m.cont.table.SetHeight(min(m.height, visibleRows))7373 case weeklyView:7474 m = m.refreshWeeklyBrowser()7575 case helpSelectView:76- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)77- m.nav.helpSelectList.SetSize(listWidth, listHeight)76+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)77+ m.help.selectList.SetSize(listWidth, listHeight)7878 case helpDetailView:7979 m = m.updateHelpDetailViewport()8080 case themeSelectView:
M app/state_view.go +4 -4
M app/update_test.go +19 -21
@@ -203,7 +203,7 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
203203 m := model{204204 state: seedInputView,205205 store: s,206- nav: navigationState{seedInput: ti},206+ seed: seedState{input: ti},207207 }208208 209209 next, _ := m.handleSeedConfirm()
@@ -232,27 +232,25 @@ func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {
232232 233233 m := model{234234 state: seedInputView,235- nav: navigationState{236- seedModeOptions: options,237- seedFocus: seedFocusMode,235+ seed: seedState{236+ modeOptions: options,237+ focus: seedFocusMode,238238 },239239 }240240 241241 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})242242 got := next.(model)243243 244- if got.nav.seedModeIndex != 1 {245- t.Fatalf("seedModeIndex = %d, want 1", got.nav.seedModeIndex)244+ if got.seed.modeIndex != 1 {245+ t.Fatalf("seedModeIndex = %d, want 1", got.seed.modeIndex)246246 }247- if got.nav.lastSeedModeKey != options[1].key {248- t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key)247+ if got.seed.lastModeKey != options[1].key {248+ t.Fatalf("lastSeedModeKey = %q, want %q", got.seed.lastModeKey, options[1].key)249249 }250250 251251 reopenedModel, _ := (model{252252 state: playMenuView,253- nav: navigationState{254- lastSeedModeKey: got.nav.lastSeedModeKey,255- },253+ seed: seedState{lastModeKey: got.seed.lastModeKey},256254 }).enterSeedInputView()257255 258256 if reopenedModel.state != seedInputView {
@@ -276,10 +274,10 @@ func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {
276274 m := model{277275 state: seedInputView,278276 store: s,279- nav: navigationState{280- seedInput: ti,281- seedModeOptions: options,282- seedModeIndex: 1,277+ seed: seedState{278+ input: ti,279+ modeOptions: options,280+ modeIndex: 1,283281 },284282 }285283
@@ -491,8 +489,8 @@ func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {
491489 weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},492490 completionSaved: false,493491 },494- nav: navigationState{495- weeklyCursor: weekly.StartOfWeek(year, weekNumber, time.Local),492+ weekly: weeklyState{493+ cursor: weekly.StartOfWeek(year, weekNumber, time.Local),496494 },497495 }498496
@@ -528,14 +526,14 @@ func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {
528526 529527 m := model{530528 store: s,531- nav: navigationState{532- weeklyCursor: currentCursor,529+ weekly: weeklyState{530+ cursor: currentCursor,533531 },534532 }535533 536534 got := m.moveWeeklyWeek(1)537- if !got.nav.weeklyCursor.Equal(currentCursor) {538- t.Fatalf("weeklyCursor = %v, want %v", got.nav.weeklyCursor, currentCursor)535+ if !got.weekly.cursor.Equal(currentCursor) {536+ t.Fatalf("weeklyCursor = %v, want %v", got.weekly.cursor, currentCursor)539537 }540538 }541539
M app/view.go +3 -3
M app/weekly.go +18 -17
@@ -39,7 +39,7 @@ func (r weeklyRow) tableRow() table.Row {
3939 4040 func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {4141 current := weekly.Current(time.Now())42- m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)42+ m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)4343 m = m.refreshWeeklyBrowser()4444 m.state = weeklyView4545 return m, nil
@@ -56,16 +56,16 @@ func (m model) refreshWeeklyBrowser() model {
5656 }5757 5858 if m.isCurrentWeeklySelection() {59- m.nav.weeklyRows = buildCurrentWeeklyRows(year, weekNumber, games)59+ m.weekly.rows = buildCurrentWeeklyRows(year, weekNumber, games)6060 } else {61- m.nav.weeklyRows = buildReviewWeeklyRows(games)61+ m.weekly.rows = buildReviewWeeklyRows(games)6262 }6363 64- rows := make([]table.Row, 0, len(m.nav.weeklyRows))65- for _, row := range m.nav.weeklyRows {64+ rows := make([]table.Row, 0, len(m.weekly.rows))65+ for _, row := range m.weekly.rows {6666 rows = append(rows, row.tableRow())6767 }68- m.nav.weeklyTable = ui.InitWeeklyTable(rows, m.height)68+ m.weekly.table = ui.InitWeeklyTable(rows, m.height)6969 return m7070 }7171
@@ -76,7 +76,7 @@ func (m model) isCurrentWeeklySelection() bool {
7676 }7777 7878 func (m model) selectedWeek() (int, int) {79- cursor := m.nav.weeklyCursor79+ cursor := m.weekly.cursor8080 if cursor.IsZero() {8181 current := weekly.Current(time.Now())8282 cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)
@@ -90,29 +90,29 @@ func (m model) weeklyPanelTitle() string {
9090 }9191 9292 func (m model) moveWeeklyWeek(delta int) model {93- if m.nav.weeklyCursor.IsZero() {93+ if m.weekly.cursor.IsZero() {9494 current := weekly.Current(time.Now())95- m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)95+ m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)9696 }9797 98- nextCursor := weekly.AddWeeks(m.nav.weeklyCursor, delta)98+ nextCursor := weekly.AddWeeks(m.weekly.cursor, delta)9999 current := weekly.Current(time.Now())100100 currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)101101 if nextCursor.After(currentCursor) {102102 nextCursor = currentCursor103103 }104104 105- m.nav.weeklyCursor = nextCursor105+ m.weekly.cursor = nextCursor106106 return m.refreshWeeklyBrowser()107107 }108108 109109 func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {110- idx := m.nav.weeklyTable.Cursor()111- if idx < 0 || idx >= len(m.nav.weeklyRows) {110+ idx := m.weekly.table.Cursor()111+ if idx < 0 || idx >= len(m.weekly.rows) {112112 return m, nil113113 }114114 115- return m.openWeeklyRow(m.nav.weeklyRows[idx])115+ return m.openWeeklyRow(m.weekly.rows[idx])116116 }117117 118118 func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
@@ -165,6 +165,7 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
165165 name: row.Name,166166 gameType: gameType,167167 modeTitle: modeTitle,168+ run: store.WeeklyRunMetadata(info.Year, info.Week, info.Index),168169 returnState: weeklyView,169170 exitState: weeklyView,170171 weeklyInfo: &infoCopy,
@@ -181,14 +182,14 @@ func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {
181182 182183 info := *m.session.weeklyAdvance183184 m = m.persistCompletionIfSolved()184- m.nav.weeklyCursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)185+ m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)185186 m = m.refreshWeeklyBrowser()186187 m.state = weeklyView187- if len(m.nav.weeklyRows) == 0 || !m.nav.weeklyRows[0].Playable {188+ if len(m.weekly.rows) == 0 || !m.weekly.rows[0].Playable {188189 return m, nil, true189190 }190191 191- next, cmd := m.openWeeklyRow(m.nav.weeklyRows[0])192+ next, cmd := m.openWeeklyRow(m.weekly.rows[0])192193 return next.(model), cmd, true193194 }194195
M architecture_test.go +64 -0
@@ -3,6 +3,7 @@ package main_test
33 import (44 "go/parser"55 "go/token"6+ "os"67 "path/filepath"78 "strings"89 "testing"
@@ -54,6 +55,43 @@ func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {
5455 })5556 }5657 58+func TestBuiltinPrintDoesNotImportConcreteGames(t *testing.T) {59+ assertPackageDoesNotImport(t, "builtinprint", []string{60+ "github.com/FelineStateMachine/puzzletea/fillomino",61+ "github.com/FelineStateMachine/puzzletea/hashiwokakero",62+ "github.com/FelineStateMachine/puzzletea/hitori",63+ "github.com/FelineStateMachine/puzzletea/lightsout",64+ "github.com/FelineStateMachine/puzzletea/nonogram",65+ "github.com/FelineStateMachine/puzzletea/nurikabe",66+ "github.com/FelineStateMachine/puzzletea/rippleeffect",67+ "github.com/FelineStateMachine/puzzletea/shikaku",68+ "github.com/FelineStateMachine/puzzletea/spellpuzzle",69+ "github.com/FelineStateMachine/puzzletea/sudoku",70+ "github.com/FelineStateMachine/puzzletea/sudokurgb",71+ "github.com/FelineStateMachine/puzzletea/takuzu",72+ "github.com/FelineStateMachine/puzzletea/takuzuplus",73+ "github.com/FelineStateMachine/puzzletea/wordsearch",74+ })75+}76+77+func TestSessionPackageDoesNotUseNameDerivedRunMetadata(t *testing.T) {78+ assertFilesDoNotContain(t, "session", []string{79+ "RunKindForName(",80+ "RunDateForName(",81+ "SeedTextForName(",82+ "WeeklyIdentityForName(",83+ })84+}85+86+func TestPDFExportPackageDoesNotContainMarkdownParseEntrypoints(t *testing.T) {87+ assertFilesDoNotContain(t, "pdfexport", []string{88+ "func ParseMarkdown(",89+ "func ParseFile(",90+ "func ParseFiles(",91+ "func lookupMarkdownBodyParser(",92+ })93+}94+5795 func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {5896 t.Helper()5997
@@ -81,3 +119,29 @@ func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {
81119 }82120 }83121 }122+123+func assertFilesDoNotContain(t *testing.T, dir string, forbidden []string) {124+ t.Helper()125+126+ files, err := filepath.Glob(filepath.Join(dir, "*.go"))127+ if err != nil {128+ t.Fatalf("glob %s: %v", dir, err)129+ }130+131+ for _, path := range files {132+ if strings.HasSuffix(path, "_test.go") {133+ continue134+ }135+136+ data, err := os.ReadFile(path)137+ if err != nil {138+ t.Fatalf("read %s: %v", path, err)139+ }140+ content := string(data)141+ for _, pattern := range forbidden {142+ if strings.Contains(content, pattern) {143+ t.Fatalf("%s contains forbidden pattern %q", path, pattern)144+ }145+ }146+ }147+}
M builtinprint/register.go +6 -30
@@ -3,43 +3,19 @@ package builtinprint
33 import (44 "sync"55 6- "github.com/FelineStateMachine/puzzletea/fillomino"7- "github.com/FelineStateMachine/puzzletea/hashiwokakero"8- "github.com/FelineStateMachine/puzzletea/hitori"9- "github.com/FelineStateMachine/puzzletea/nonogram"10- "github.com/FelineStateMachine/puzzletea/nurikabe"116 "github.com/FelineStateMachine/puzzletea/pdfexport"12- "github.com/FelineStateMachine/puzzletea/rippleeffect"13- "github.com/FelineStateMachine/puzzletea/shikaku"14- "github.com/FelineStateMachine/puzzletea/spellpuzzle"15- "github.com/FelineStateMachine/puzzletea/sudoku"16- "github.com/FelineStateMachine/puzzletea/sudokurgb"17- "github.com/FelineStateMachine/puzzletea/takuzu"18- "github.com/FelineStateMachine/puzzletea/takuzuplus"19- "github.com/FelineStateMachine/puzzletea/wordsearch"7+ "github.com/FelineStateMachine/puzzletea/registry"208 )219 2210 var registerOnce sync.Once2311 2412 func Register() {2513 registerOnce.Do(func() {26- adapters := []pdfexport.PrintAdapter{27- fillomino.PDFPrintAdapter,28- hashiwokakero.PDFPrintAdapter,29- hitori.PDFPrintAdapter,30- nonogram.PDFPrintAdapter,31- nurikabe.PDFPrintAdapter,32- rippleeffect.PDFPrintAdapter,33- shikaku.PDFPrintAdapter,34- spellpuzzle.PDFPrintAdapter,35- sudoku.PDFPrintAdapter,36- sudokurgb.PDFPrintAdapter,37- takuzu.PDFPrintAdapter,38- takuzuplus.PDFPrintAdapter,39- wordsearch.PDFPrintAdapter,40- }41- for _, adapter := range adapters {42- pdfexport.RegisterPrintAdapter(adapter)14+ for _, entry := range registry.Entries() {15+ if entry.Print == nil {16+ continue17+ }18+ pdfexport.RegisterPrintAdapter(entry.Print)4319 }4420 })4521 }
M builtinprint/register_test.go +8 -19
M cmd/export_pdf_test.go +7 -3
M cmd/new.go +2 -2
M cmd/new_export.go +1 -1
M cmd/new_export_test.go +5 -2
M fillomino/gamemode.go +1 -0
M gamereg/gamereg.go +3 -0
M gamereg/gamereg_test.go +3 -0
M hashiwokakero/gamemode.go +1 -0
M hitori/gamemode.go +1 -0
M nonogram/gamemode.go +1 -0
M nurikabe/gamemode.go +1 -0
M pdfexport/jsonl.go +15 -0
M pdfexport/jsonl_test.go +33 -30
@@ -95,6 +95,39 @@ func TestParseJSONLFileRejectsNonJSONLExtension(t *testing.T) {
9595 }9696 }9797 98+func TestParseJSONLFileRejectsFilesWithoutPrintablePuzzles(t *testing.T) {99+ path := filepath.Join(t.TempDir(), "lights.jsonl")100+ record := JSONLRecord{101+ Schema: ExportSchemaV1,102+ Pack: JSONLPackMeta{103+ Generated: "2026-02-22T10:00:00Z",104+ Version: "v-test",105+ Category: "Lights Out",106+ ModeSelection: "Standard",107+ Count: 1,108+ },109+ Puzzle: JSONLPuzzle{110+ Index: 1,111+ Name: "glow-shore",112+ Game: "Lights Out",113+ Mode: "Standard",114+ Save: json.RawMessage(`{"size":5}`),115+ },116+ }117+ writeSingleJSONLRecord(t, path, record)118+119+ _, err := ParseJSONLFile(path)120+ if err == nil {121+ t.Fatal("expected no-printable-puzzles error")122+ }123+ if !strings.Contains(err.Error(), "no printable puzzles found") {124+ t.Fatalf("unexpected error: %v", err)125+ }126+ if !strings.Contains(err.Error(), "Lights Out") {127+ t.Fatalf("expected unsupported category in error, got: %v", err)128+ }129+}130+98131 func TestParseJSONLFileHydratesTakuzuFromSave(t *testing.T) {99132 path := filepath.Join(t.TempDir(), "takuzu-pack.jsonl")100133 record := JSONLRecord{
@@ -401,36 +434,6 @@ func TestParseJSONLFileHydratesHashiFromSave(t *testing.T) {
401434 }402435 }403436 404-func TestParseJSONLFileSilentlySkipsUnsupportedGame(t *testing.T) {405- path := filepath.Join(t.TempDir(), "lights.jsonl")406- record := JSONLRecord{407- Schema: ExportSchemaV1,408- Pack: JSONLPackMeta{409- Generated: "2026-02-22T10:00:00Z",410- Version: "v-test",411- Category: "Lights Out",412- ModeSelection: "Standard",413- Count: 1,414- },415- Puzzle: JSONLPuzzle{416- Index: 1,417- Name: "glow-shore",418- Game: "Lights Out",419- Mode: "Standard",420- Save: json.RawMessage(`{"size":5}`),421- },422- }423- writeSingleJSONLRecord(t, path, record)424-425- doc, err := ParseJSONLFile(path)426- if err != nil {427- t.Fatalf("expected silent no-op for unsupported game, got: %v", err)428- }429- if got := len(doc.Puzzles); got != 0 {430- t.Fatalf("puzzles = %d, want 0", got)431- }432-}433-434437 func TestParseJSONLFileMetadataFromFirstPrintableRecord(t *testing.T) {435438 path := filepath.Join(t.TempDir(), "mixed.jsonl")436439
D pdfexport/parse.go +0 -488
@@ -1,488 +0,0 @@
1-package pdfexport2-3-import (4- "fmt"5- "os"6- "path/filepath"7- "regexp"8- "strconv"9- "strings"10- "time"11-)12-13-var (14- puzzleHeadingPattern = regexp.MustCompile(`^##\s+(.+?)\s+-\s+(\d+)\s*$`)15- nonogramRowHeaderRegex = regexp.MustCompile(`^R\d+$`)16- nonogramColHeaderRegex = regexp.MustCompile(`^C\d+$`)17- tableSepCellRegex = regexp.MustCompile(`^:?-{3,}:?$`)18-)19-20-func ParseFiles(paths []string) ([]PackDocument, error) {21- docs := make([]PackDocument, 0, len(paths))22- for _, path := range paths {23- doc, err := ParseFile(path)24- if err != nil {25- return nil, err26- }27- docs = append(docs, doc)28- }29- return docs, nil30-}31-32-func ParseFile(path string) (PackDocument, error) {33- data, err := os.ReadFile(path)34- if err != nil {35- return PackDocument{}, fmt.Errorf("read input markdown: %w", err)36- }37- return ParseMarkdown(path, string(data))38-}39-40-func ParseMarkdown(path, content string) (PackDocument, error) {41- content = strings.ReplaceAll(content, "\r\n", "\n")42- content = strings.ReplaceAll(content, "\r", "\n")43- lines := strings.Split(content, "\n")44-45- firstContentLine := firstNonEmptyLine(lines)46- if firstContentLine < 0 {47- return PackDocument{}, parseError(path, 1, "input markdown is empty")48- }49- if strings.TrimSpace(lines[firstContentLine]) != "# PuzzleTea Export" {50- return PackDocument{}, parseError(path, firstContentLine+1, "expected markdown title '# PuzzleTea Export'")51- }52-53- headings := findHeadingLines(lines)54- if len(headings) == 0 {55- return PackDocument{}, parseError(path, firstContentLine+1, "expected at least one puzzle section heading")56- }57-58- meta, err := parseMetadata(lines[firstContentLine+1:headings[0]], path, firstContentLine+2)59- if err != nil {60- return PackDocument{}, err61- }62- meta.SourceFileName = filepath.Base(path)63-64- puzzles := make([]Puzzle, 0, len(headings))65- for i, start := range headings {66- end := len(lines)67- if i+1 < len(headings) {68- end = headings[i+1]69- }70-71- puzzle, err := parsePuzzleSection(lines[start:end], path, start+1, meta)72- if err != nil {73- return PackDocument{}, err74- }75- puzzles = append(puzzles, puzzle)76- }77-78- if len(puzzles) == 0 {79- return PackDocument{}, parseError(path, firstContentLine+1, "no puzzle sections were parsed")80- }81-82- return PackDocument{83- SourcePath: path,84- Metadata: meta,85- Puzzles: puzzles,86- }, nil87-}88-89-func parseMetadata(lines []string, path string, startLine int) (PackMetadata, error) {90- meta := PackMetadata{}91-92- for i, line := range lines {93- trimmed := strings.TrimSpace(line)94- if trimmed == "" {95- continue96- }97- if !strings.HasPrefix(trimmed, "- ") {98- continue99- }100-101- entry := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))102- key, value, ok := strings.Cut(entry, ":")103- if !ok {104- continue105- }106-107- lineNo := startLine + i108- key = strings.ToLower(strings.TrimSpace(key))109- value = strings.TrimSpace(value)110-111- switch key {112- case "generated":113- meta.GeneratedRaw = value114- if ts, err := time.Parse(time.RFC3339, value); err == nil {115- meta.GeneratedAt = ts116- }117- case "version":118- meta.Version = value119- case "category":120- meta.Category = value121- case "mode selection":122- meta.ModeSelection = value123- case "count":124- count, err := strconv.Atoi(value)125- if err != nil {126- return PackMetadata{}, parseError(path, lineNo, "invalid Count value %q", value)127- }128- meta.Count = count129- case "seed":130- meta.Seed = value131- case "export format":132- meta.Format = value133- }134- }135-136- if strings.TrimSpace(meta.Category) == "" {137- return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Category")138- }139- if strings.TrimSpace(meta.ModeSelection) == "" {140- return PackMetadata{}, parseError(path, startLine, "missing required metadata field: Mode Selection")141- }142-143- return meta, nil144-}145-146-func parsePuzzleSection(section []string, path string, startLine int, meta PackMetadata) (Puzzle, error) {147- if len(section) == 0 {148- return Puzzle{}, parseError(path, startLine, "empty puzzle section")149- }150-151- heading := strings.TrimSpace(section[0])152- matches := puzzleHeadingPattern.FindStringSubmatch(heading)153- if len(matches) != 3 {154- return Puzzle{}, parseError(path, startLine, "invalid puzzle heading %q", heading)155- }156-157- index, err := strconv.Atoi(matches[2])158- if err != nil {159- return Puzzle{}, parseError(path, startLine, "invalid puzzle index %q", matches[2])160- }161-162- bodyLines := append([]string(nil), section[1:]...)163- trimSectionBody(&bodyLines)164- body := strings.Join(bodyLines, "\n")165-166- p := Puzzle{167- SourcePath: path,168- SourceFileName: meta.SourceFileName,169- Category: meta.Category,170- ModeSelection: meta.ModeSelection,171- Name: matches[1],172- Index: index,173- Body: body,174- }175-176- parser := lookupMarkdownBodyParser(meta.Category)177- payload, err := parser.ParseMarkdownBody(bodyLines, path, startLine+1)178- if err != nil {179- return Puzzle{}, err180- }181- p.PrintPayload = payload182-183- return p, nil184-}185-186-func parseNonogramBody(bodyLines []string, path string, bodyStartLine int) (*NonogramData, error) {187- tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine)188- if len(tableLines) < 3 {189- lineNo := bodyStartLine190- if len(tableLineNumbers) > 0 {191- lineNo = tableLineNumbers[0]192- }193- return nil, parseError(path, lineNo, "expected nonogram markdown table with header, separator, and data rows")194- }195-196- header := parseTableRow(tableLines[0])197- if len(header) == 0 {198- return nil, parseError(path, tableLineNumbers[0], "nonogram header row is empty")199- }200-201- rowHintCols := 0202- colCount := 0203- for _, cell := range header {204- switch {205- case nonogramRowHeaderRegex.MatchString(cell):206- rowHintCols++207- case nonogramColHeaderRegex.MatchString(cell):208- colCount++209- }210- }211- if rowHintCols < 1 || colCount < 1 {212- return nil, parseError(path, tableLineNumbers[0], "expected nonogram header cells like R1.. and C1..")213- }214-215- expectedCols := rowHintCols + colCount216- dataRows := make([][]string, 0, len(tableLines)-2)217- for i := 2; i < len(tableLines); i++ {218- cells := parseTableRow(tableLines[i])219- if len(cells) < expectedCols {220- return nil, parseError(path, tableLineNumbers[i], "expected %d columns, found %d", expectedCols, len(cells))221- }222- if len(cells) > expectedCols {223- cells = cells[:expectedCols]224- }225- dataRows = append(dataRows, cells)226- }227-228- if len(dataRows) == 0 {229- return nil, parseError(path, tableLineNumbers[0], "nonogram table has no data rows")230- }231-232- colHintRows := 0233- for _, row := range dataRows {234- if rowHintPlaceholderRow(row[:rowHintCols]) {235- colHintRows++236- continue237- }238- break239- }240-241- height := len(dataRows) - colHintRows242- if height <= 0 {243- return nil, parseError(path, tableLineNumbers[len(tableLineNumbers)-1], "nonogram table does not contain puzzle rows")244- }245-246- rowHints := make([][]int, height)247- grid := make([][]string, height)248- for y := range height {249- row := dataRows[colHintRows+y]250- hints := parseHintCells(row[:rowHintCols])251- if len(hints) == 0 {252- hints = []int{0}253- }254- rowHints[y] = hints255-256- gridRow := make([]string, colCount)257- for x := 0; x < colCount; x++ {258- cell := strings.TrimSpace(row[rowHintCols+x])259- switch cell {260- case "", ".":261- gridRow[x] = " "262- default:263- gridRow[x] = cell264- }265- }266- grid[y] = gridRow267- }268-269- colHints := make([][]int, colCount)270- for x := 0; x < colCount; x++ {271- hints := make([]int, 0, colHintRows)272- for r := 0; r < colHintRows; r++ {273- if v, ok := parseHintValue(dataRows[r][rowHintCols+x]); ok {274- hints = append(hints, v)275- }276- }277- if len(hints) == 0 {278- hints = []int{0}279- }280- colHints[x] = hints281- }282-283- return &NonogramData{284- Width: colCount,285- Height: height,286- RowHints: rowHints,287- ColHints: colHints,288- Grid: grid,289- }, nil290-}291-292-func parseGridTableBody(bodyLines []string, path string, bodyStartLine int) (*GridTable, error) {293- tableLines, tableLineNumbers := findFirstMarkdownTable(bodyLines, bodyStartLine)294- if len(tableLines) == 0 {295- return nil, nil296- }297- if len(tableLines) < 2 {298- return nil, parseError(path, tableLineNumbers[0], "expected markdown table to include data rows")299- }300-301- rows := make([][]string, 0, len(tableLines))302- for i, line := range tableLines {303- cells := parseTableRow(line)304- if len(cells) == 0 {305- return nil, parseError(path, tableLineNumbers[i], "empty table row")306- }307- rows = append(rows, cells)308- }309-310- hasHeaderRow := false311- if len(rows) > 1 && isMarkdownSeparatorRow(rows[1]) {312- hasHeaderRow = true313- rows = append(rows[:1], rows[2:]...)314- }315- if len(rows) < 2 {316- return nil, parseError(path, tableLineNumbers[0], "table must include header and data rows")317- }318-319- width := 0320- for _, row := range rows {321- if len(row) > width {322- width = len(row)323- }324- }325- for i := range rows {326- if len(rows[i]) >= width {327- continue328- }329- padded := make([]string, width)330- copy(padded, rows[i])331- rows[i] = padded332- }333-334- return &GridTable{335- Rows: rows,336- HasHeaderRow: hasHeaderRow,337- HasHeaderCol: detectHeaderColumn(rows, hasHeaderRow),338- }, nil339-}340-341-func findFirstMarkdownTable(lines []string, startLine int) ([]string, []int) {342- table := []string{}343- lineNumbers := []int{}344- started := false345-346- for i, line := range lines {347- trimmed := strings.TrimSpace(line)348- if strings.HasPrefix(trimmed, "|") {349- started = true350- table = append(table, line)351- lineNumbers = append(lineNumbers, startLine+i)352- continue353- }354- if started {355- break356- }357- }358-359- return table, lineNumbers360-}361-362-func parseTableRow(line string) []string {363- trimmed := strings.TrimSpace(line)364- trimmed = strings.TrimPrefix(trimmed, "|")365- trimmed = strings.TrimSuffix(trimmed, "|")366- if trimmed == "" {367- return []string{}368- }369-370- parts := strings.Split(trimmed, "|")371- cells := make([]string, 0, len(parts))372- for _, part := range parts {373- cells = append(cells, strings.TrimSpace(part))374- }375- return cells376-}377-378-func isMarkdownSeparatorRow(cells []string) bool {379- if len(cells) == 0 {380- return false381- }382- for _, cell := range cells {383- if !tableSepCellRegex.MatchString(strings.TrimSpace(cell)) {384- return false385- }386- }387- return true388-}389-390-func rowHintPlaceholderRow(cells []string) bool {391- for _, cell := range cells {392- trimmed := strings.TrimSpace(cell)393- if trimmed != "" && trimmed != "." {394- return false395- }396- }397- return true398-}399-400-func parseHintCells(cells []string) []int {401- hints := make([]int, 0, len(cells))402- for _, cell := range cells {403- if v, ok := parseHintValue(cell); ok {404- hints = append(hints, v)405- }406- }407- return hints408-}409-410-func detectHeaderColumn(rows [][]string, hasHeaderRow bool) bool {411- if len(rows) < 2 {412- return false413- }414-415- start := 1416- if !hasHeaderRow {417- start = 0418- }419-420- total := 0421- numeric := 0422- for i := start; i < len(rows); i++ {423- if len(rows[i]) == 0 {424- continue425- }426- total++427- if _, err := strconv.Atoi(strings.TrimSpace(rows[i][0])); err == nil {428- numeric++429- }430- }431-432- return total > 0 && numeric*100/total >= 70433-}434-435-func parseHintValue(cell string) (int, bool) {436- trimmed := strings.TrimSpace(cell)437- if trimmed == "" || trimmed == "." {438- return 0, false439- }440- v, err := strconv.Atoi(trimmed)441- if err != nil {442- return 0, false443- }444- return v, true445-}446-447-func trimSectionBody(lines *[]string) {448- for len(*lines) > 0 {449- trimmed := strings.TrimSpace((*lines)[0])450- if trimmed != "" && trimmed != "---" {451- break452- }453- *lines = (*lines)[1:]454- }455- for len(*lines) > 0 {456- trimmed := strings.TrimSpace((*lines)[len(*lines)-1])457- if trimmed != "" && trimmed != "---" {458- break459- }460- *lines = (*lines)[:len(*lines)-1]461- }462-}463-464-func firstNonEmptyLine(lines []string) int {465- for i, line := range lines {466- if strings.TrimSpace(line) != "" {467- return i468- }469- }470- return -1471-}472-473-func findHeadingLines(lines []string) []int {474- indexes := []int{}475- for i, line := range lines {476- if strings.HasPrefix(strings.TrimSpace(line), "## ") {477- indexes = append(indexes, i)478- }479- }480- return indexes481-}482-483-func parseError(path string, line int, format string, args ...any) error {484- if line < 1 {485- line = 1486- }487- return fmt.Errorf("%s:%d: %s", path, line, fmt.Sprintf(format, args...))488-}
D pdfexport/parse_handlers.go +0 -31
D pdfexport/parse_test.go +0 -134
@@ -1,134 +0,0 @@
1-package pdfexport2-3-import (4- "os"5- "path/filepath"6- "strconv"7- "testing"8-)9-10-func TestParseMarkdownNonogram(t *testing.T) {11- doc, err := ParseMarkdown("sample.md", sampleNonogramDoc("Standard", 1))12- if err != nil {13- t.Fatal(err)14- }15-16- if got, want := doc.Metadata.Category, "Nonogram"; got != want {17- t.Fatalf("category = %q, want %q", got, want)18- }19- if got, want := len(doc.Puzzles), 1; got != want {20- t.Fatalf("puzzles = %d, want %d", got, want)21- }22-23- p := doc.Puzzles[0]24- nonogram, ok := p.PrintPayload.(*NonogramData)25- if !ok || nonogram == nil {26- t.Fatal("expected parsed nonogram data")27- }28- if nonogram.Width != 2 || nonogram.Height != 2 {29- t.Fatalf("nonogram size = %dx%d, want 2x2", nonogram.Width, nonogram.Height)30- }31-32- if got, want := nonogram.RowHints[0][0], 1; got != want {33- t.Fatalf("first row first hint = %d, want %d", got, want)34- }35- if got, want := nonogram.ColHints[0][0], 1; got != want {36- t.Fatalf("first col first hint = %d, want %d", got, want)37- }38-39- if got, want := nonogram.Grid[0][0], " "; got != want {40- t.Fatalf("grid dot replacement = %q, want %q", got, want)41- }42-}43-44-func TestParseFilesMultipleInputs(t *testing.T) {45- temp := t.TempDir()46-47- fileA := filepath.Join(temp, "pack-a.md")48- if err := os.WriteFile(fileA, []byte(sampleNonogramDoc("Standard", 1)), 0o644); err != nil {49- t.Fatal(err)50- }51-52- fileB := filepath.Join(temp, "pack-b.md")53- if err := os.WriteFile(fileB, []byte(sampleNonogramDoc("Classic", 2)), 0o644); err != nil {54- t.Fatal(err)55- }56-57- docs, err := ParseFiles([]string{fileA, fileB})58- if err != nil {59- t.Fatal(err)60- }61- if got, want := len(docs), 2; got != want {62- t.Fatalf("docs = %d, want %d", got, want)63- }64-65- if got, want := docs[0].Metadata.SourceFileName, "pack-a.md"; got != want {66- t.Fatalf("first source file = %q, want %q", got, want)67- }68- if got, want := docs[1].Puzzles[0].ModeSelection, "Classic"; got != want {69- t.Fatalf("second mode selection = %q, want %q", got, want)70- }71-}72-73-func TestParseMarkdownTakuzuTable(t *testing.T) {74- doc, err := ParseMarkdown("takuzu.md", sampleTakuzuDoc())75- if err != nil {76- t.Fatal(err)77- }78-79- if got, want := doc.Metadata.Category, "Takuzu"; got != want {80- t.Fatalf("category = %q, want %q", got, want)81- }82-83- p := doc.Puzzles[0]84- table, ok := p.PrintPayload.(*GridTable)85- if !ok || table == nil {86- t.Fatal("expected parsed grid table for takuzu")87- }88- if !table.HasHeaderRow {89- t.Fatal("expected takuzu table to detect a header row")90- }91- if !table.HasHeaderCol {92- t.Fatal("expected takuzu table to detect a header column")93- }94-95- if got, want := table.Rows[1][1], "."; got != want {96- t.Fatalf("table cell = %q, want %q", got, want)97- }98-}99-100-func sampleNonogramDoc(mode string, idx int) string {101- return "# PuzzleTea Export\n\n" +102- "- Generated: 2026-02-21T20:42:05-07:00\n" +103- "- Version: v1.6.0\n" +104- "- Category: Nonogram\n" +105- "- Mode Selection: " + mode + "\n" +106- "- Count: 1\n" +107- "- Seed: zine\n\n" +108- "## ember-newt - " + strconv.Itoa(idx) + "\n\n" +109- "### Puzzle Grid with Integrated Hints\n\n" +110- "| R1 | R2 | C1 | C2 |\n" +111- "| --- | --- | --- | --- |\n" +112- "| . | . | 1 | 2 |\n" +113- "| . | . | 3 | 4 |\n" +114- "| 1 | 1 | . | . |\n" +115- "| . | 2 | . | . |\n\n" +116- "Row hints are right-aligned beside each row.\n"117-}118-119-func sampleTakuzuDoc() string {120- return "# PuzzleTea Export\n\n" +121- "- Generated: 2026-02-21T20:42:05-07:00\n" +122- "- Version: v1.6.0\n" +123- "- Category: Takuzu\n" +124- "- Mode Selection: Beginner\n" +125- "- Count: 1\n" +126- "- Seed: zine\n\n" +127- "## scarlet-lichen - 1\n\n" +128- "### Given Grid\n\n" +129- "| | 1 | 2 | 3 |\n" +130- "| --- | --- | --- | --- |\n" +131- "| 1 | . | 0 | . |\n" +132- "| 2 | 1 | . | 0 |\n\n" +133- "Goal: fill with 0/1.\n"134-}
M registry/registry_test.go +14 -0
M rippleeffect/gamemode.go +1 -0
M session/session.go +7 -8
M session/session_test.go +26 -1
@@ -3,6 +3,7 @@ package session
33 import (44 "path/filepath"55 "testing"6+ "time"67 78 "github.com/FelineStateMachine/puzzletea/lightsout"89 "github.com/FelineStateMachine/puzzletea/store"
@@ -77,7 +78,7 @@ func TestCreateRecordAndResumeAbandonedDeterministicRecord(t *testing.T) {
7778 if err != nil {7879 t.Fatal(err)7980 }80- rec, err := CreateRecord(s, g, "demo", "Lights Out", "Easy")81+ rec, err := CreateRecord(s, g, "demo", "Lights Out", "Easy", store.NormalRunMetadata())8182 if err != nil {8283 t.Fatalf("CreateRecord returned error: %v", err)8384 }
@@ -105,6 +106,30 @@ func TestCreateRecordAndResumeAbandonedDeterministicRecord(t *testing.T) {
105106 }106107 }107108 109+func TestCreateRecordUsesExplicitRunMetadata(t *testing.T) {110+ s := openSessionTestStore(t)111+112+ g, err := lightsout.New(3, 3)113+ if err != nil {114+ t.Fatal(err)115+ }116+117+ day := time.Date(2026, 2, 14, 15, 30, 0, 0, time.Local)118+ rec, err := CreateRecord(s, g, "plain-name", "Lights Out", "Easy", store.DailyRunMetadata(day))119+ if err != nil {120+ t.Fatalf("CreateRecord returned error: %v", err)121+ }122+ if rec.RunKind != store.RunKindDaily {123+ t.Fatalf("RunKind = %q, want %q", rec.RunKind, store.RunKindDaily)124+ }125+ if rec.RunDate == nil {126+ t.Fatal("RunDate = nil, want daily date")127+ }128+ if got, want := rec.RunDate.Format("2006-01-02"), "2026-02-14"; got != want {129+ t.Fatalf("RunDate = %q, want %q", got, want)130+ }131+}132+108133 func openSessionTestStore(t *testing.T) *store.Store {109134 t.Helper()110135 s, err := store.Open(filepath.Join(t.TempDir(), "session.db"))
M shikaku/gamemode.go +1 -0
M spellpuzzle/gamemode.go +1 -0
M store/game_record.go +9 -0
M store/metadata.go +33 -1
M sudoku/gamemode.go +1 -0
M sudokurgb/gamemode.go +1 -0
M takuzu/gamemode.go +1 -0
M takuzuplus/gamemode.go +1 -0
M wordsearch/gamemode.go +1 -0