clone

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

refactor and ripple effect difficulty up

Dami committed 8f423c4
commit
8f423c4febadb26efb177d69c6da6d2c924ae945
parent
5845928737dc6f8dd7072324272a8666b05173e7
author
Dami <dami.etoile@gmail.com>
301 files changed, +635 -445
Mapp/export.go111+++++++++++++++-------
Mapp/export_test.go68++++++-------
Mapp/handle_daily.go2+-
Mapp/handle_game.go51----------
Mapp/handle_help.go6+-
Mapp/handle_seed.go2+-
Mapp/handle_stats.go2+-
Mapp/handle_theme.go4+-
Mapp/help_layout_test.go8+-
Mapp/model.go1-
Mapp/notice.go4-
Mapp/screen_core.go110++++++++++++----------
Mapp/screen_navigation.go79+++++----------
Mapp/screen_support.go43++------
Mapp/seed_input.go26+++--
Mapp/update.go18++-
Mapp/update_test.go11+-
Mapp/view.go42++++----
Mapp/view_helpers.go24++--
Mapp/weekly.go31++++--
Marchitecture_test.go10+-
Mcmd/export.go2+-
Mcmd/export_pdf.go6+-
Mcmd/export_pdf_test.go2+-
Mcmd/export_test.go2+-
Mcmd/new_export.go6+-
Mcmd/new_export_test.go2+-
Mcmd/test.go2+-
Rbuiltinprint/register.go → export/builtinprint/register.go2+-
Rbuiltinprint/register_test.go → export/builtinprint/register_test.go2+-
Rpackexport/packexport_test.go → export/pack/packexport_test.go2+-
Rpackexport/render.go → export/pack/render.go2+-
Rpackexport/runtime.go → export/pack/runtime.go2+-
Rpackexport/spec.go → export/pack/spec.go4+-
Rpdfexport/font.go → export/pdf/font.go0
Rpdfexport/font_test.go → export/pdf/font_test.go0
Rpdfexport/fonts.go → export/pdf/fonts.go0
Rpdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Bold.ttf0
Rpdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt → export/pdf/fonts/AtkinsonHyperlegibleNext-OFL.txt0
Rpdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Regular.ttf0
Rpdfexport/fonts/DMSerifDisplay-OFL.txt → export/pdf/fonts/DMSerifDisplay-OFL.txt0
Rpdfexport/fonts/DMSerifDisplay-Regular.ttf → export/pdf/fonts/DMSerifDisplay-Regular.ttf0
Rpdfexport/jsonl.go → export/pdf/jsonl.go0
Rpdfexport/jsonl_test.go → export/pdf/jsonl_test.go0
Rpdfexport/normalize.go → export/pdf/normalize.go0
Rpdfexport/order.go → export/pdf/order.go0
Rpdfexport/order_test.go → export/pdf/order_test.go0
Rpdfexport/parse_fillomino.go → export/pdf/parse_fillomino.go0
Rpdfexport/parse_hashi.go → export/pdf/parse_hashi.go0
Rpdfexport/parse_helpers.go → export/pdf/parse_helpers.go0
Rpdfexport/parse_hitori.go → export/pdf/parse_hitori.go0
Rpdfexport/parse_netwalk.go → export/pdf/parse_netwalk.go0
Rpdfexport/parse_nonogram.go → export/pdf/parse_nonogram.go0
Rpdfexport/parse_nurikabe.go → export/pdf/parse_nurikabe.go0
Rpdfexport/parse_rippleeffect.go → export/pdf/parse_rippleeffect.go0
Rpdfexport/parse_shikaku.go → export/pdf/parse_shikaku.go0
Rpdfexport/parse_sudoku.go → export/pdf/parse_sudoku.go0
Rpdfexport/parse_takuzu.go → export/pdf/parse_takuzu.go0
Rpdfexport/parse_takuzuplus.go → export/pdf/parse_takuzuplus.go0
Rpdfexport/parse_wordsearch.go → export/pdf/parse_wordsearch.go0
Rpdfexport/print_adapter.go → export/pdf/print_adapter.go0
Rpdfexport/printdata_test.go → export/pdf/printdata_test.go0
Rpdfexport/render.go → export/pdf/render.go0
Rpdfexport/render_cover.go → export/pdf/render_cover.go0
Rpdfexport/render_cover_test.go → export/pdf/render_cover_test.go0
Rpdfexport/render_difficulty.go → export/pdf/render_difficulty.go0
Rpdfexport/render_difficulty_test.go → export/pdf/render_difficulty_test.go0
Rpdfexport/render_error_test.go → export/pdf/render_error_test.go0
Rpdfexport/render_instructions.go → export/pdf/render_instructions.go0
Rpdfexport/render_instructions_test.go → export/pdf/render_instructions_test.go0
Rpdfexport/render_kit.go → export/pdf/render_kit.go0
Rpdfexport/render_layout_test.go → export/pdf/render_layout_test.go0
Rpdfexport/render_metadata.go → export/pdf/render_metadata.go0
Rpdfexport/render_plan.go → export/pdf/render_plan.go0
Rpdfexport/render_title.go → export/pdf/render_title.go0
Rpdfexport/render_title_test.go → export/pdf/render_title_test.go0
Rpdfexport/render_tokens.go → export/pdf/render_tokens.go0
Rpdfexport/types.go → export/pdf/types.go0
Mgameentry/gameentry.go2+-
Rfillomino/README.md → games/fillomino/README.md0
Rfillomino/export.go → games/fillomino/export.go0
Rfillomino/fillomino_test.go → games/fillomino/fillomino_test.go0
Rfillomino/gamemode.go → games/fillomino/gamemode.go0
Rfillomino/generator.go → games/fillomino/generator.go0
Rfillomino/grid.go → games/fillomino/grid.go0
Rfillomino/help.md → games/fillomino/help.md0
Rfillomino/keys.go → games/fillomino/keys.go0
Rfillomino/model.go → games/fillomino/model.go0
Rfillomino/mouse.go → games/fillomino/mouse.go0
Rfillomino/print_adapter.go → games/fillomino/print_adapter.go2+-
Rfillomino/print_adapter_test.go → games/fillomino/print_adapter_test.go0
Rfillomino/solver.go → games/fillomino/solver.go0
Rfillomino/style.go → games/fillomino/style.go0
Rfillomino/testdata/visual_states.jsonl → games/fillomino/testdata/visual_states.jsonl0
Rhashiwokakero/README.md → games/hashiwokakero/README.md0
Rhashiwokakero/benchmark_test.go → games/hashiwokakero/benchmark_test.go0
Rhashiwokakero/export.go → games/hashiwokakero/export.go0
Rhashiwokakero/gamemode.go → games/hashiwokakero/gamemode.go0
Rhashiwokakero/generation_invariants_test.go → games/hashiwokakero/generation_invariants_test.go0
Rhashiwokakero/generator.go → games/hashiwokakero/generator.go0
Rhashiwokakero/hashiwokakero_test.go → games/hashiwokakero/hashiwokakero_test.go0
Rhashiwokakero/help.md → games/hashiwokakero/help.md0
Rhashiwokakero/island.go → games/hashiwokakero/island.go0
Rhashiwokakero/keys.go → games/hashiwokakero/keys.go0
Rhashiwokakero/model.go → games/hashiwokakero/model.go0
Rhashiwokakero/mouse.go → games/hashiwokakero/mouse.go0
Rhashiwokakero/print_adapter.go → games/hashiwokakero/print_adapter.go2+-
Rhashiwokakero/style.go → games/hashiwokakero/style.go0
Rhashiwokakero/testdata/visual_states.jsonl → games/hashiwokakero/testdata/visual_states.jsonl0
Rhitori/README.md → games/hitori/README.md0
Rhitori/benchmark_test.go → games/hitori/benchmark_test.go0
Rhitori/export.go → games/hitori/export.go0
Rhitori/gamemode.go → games/hitori/gamemode.go0
Rhitori/generator.go → games/hitori/generator.go0
Rhitori/grid.go → games/hitori/grid.go0
Rhitori/help.md → games/hitori/help.md0
Rhitori/hitori_test.go → games/hitori/hitori_test.go0
Rhitori/keys.go → games/hitori/keys.go0
Rhitori/model.go → games/hitori/model.go0
Rhitori/mouse.go → games/hitori/mouse.go0
Rhitori/print_adapter.go → games/hitori/print_adapter.go2+-
Rhitori/solver.go → games/hitori/solver.go0
Rhitori/style.go → games/hitori/style.go0
Rhitori/testdata/visual_states.jsonl → games/hitori/testdata/visual_states.jsonl0
Rlightsout/README.md → games/lightsout/README.md0
Rlightsout/benchmark_test.go → games/lightsout/benchmark_test.go0
Rlightsout/export.go → games/lightsout/export.go0
Rlightsout/gamemode.go → games/lightsout/gamemode.go0
Rlightsout/generator.go → games/lightsout/generator.go0
Rlightsout/help.md → games/lightsout/help.md0
Rlightsout/keys.go → games/lightsout/keys.go0
Rlightsout/lightsout_test.go → games/lightsout/lightsout_test.go0
Rlightsout/model.go → games/lightsout/model.go0
Rlightsout/style.go → games/lightsout/style.go0
Rlightsout/testdata/visual_states.jsonl → games/lightsout/testdata/visual_states.jsonl0
Rnetwalk/README.md → games/netwalk/README.md0
Rnetwalk/export.go → games/netwalk/export.go0
Rnetwalk/gamemode.go → games/netwalk/gamemode.go0
Rnetwalk/generator.go → games/netwalk/generator.go0
Rnetwalk/grid.go → games/netwalk/grid.go0
Rnetwalk/help.md → games/netwalk/help.md0
Rnetwalk/keys.go → games/netwalk/keys.go0
Rnetwalk/model.go → games/netwalk/model.go0
Rnetwalk/netwalk_test.go → games/netwalk/netwalk_test.go0
Rnetwalk/print_adapter.go → games/netwalk/print_adapter.go2+-
Rnetwalk/style.go → games/netwalk/style.go0
Rnetwalk/testdata/visual_states.jsonl → games/netwalk/testdata/visual_states.jsonl0
Rnonogram/README.md → games/nonogram/README.md0
Rnonogram/dynamic_grid_test.go → games/nonogram/dynamic_grid_test.go0
Rnonogram/export.go → games/nonogram/export.go0
Rnonogram/gamemode.go → games/nonogram/gamemode.go0
Rnonogram/generator.go → games/nonogram/generator.go0
Rnonogram/grid.go → games/nonogram/grid.go0
Rnonogram/help.md → games/nonogram/help.md0
Rnonogram/keys.go → games/nonogram/keys.go0
Rnonogram/model.go → games/nonogram/model.go0
Rnonogram/mouse.go → games/nonogram/mouse.go0
Rnonogram/nonogram_test.go → games/nonogram/nonogram_test.go0
Rnonogram/print_adapter.go → games/nonogram/print_adapter.go2+-
Rnonogram/print_adapter_test.go → games/nonogram/print_adapter_test.go2+-
Rnonogram/style.go → games/nonogram/style.go0
Rnonogram/testdata/visual_states.jsonl → games/nonogram/testdata/visual_states.jsonl0
Rnonogram/tomography.go → games/nonogram/tomography.go0
Rnurikabe/README.md → games/nurikabe/README.md0
Rnurikabe/export.go → games/nurikabe/export.go0
Rnurikabe/gamemode.go → games/nurikabe/gamemode.go0
Rnurikabe/generator.go → games/nurikabe/generator.go0
Rnurikabe/grid.go → games/nurikabe/grid.go0
Rnurikabe/help.md → games/nurikabe/help.md0
Rnurikabe/keys.go → games/nurikabe/keys.go0
Rnurikabe/model.go → games/nurikabe/model.go0
Rnurikabe/mouse.go → games/nurikabe/mouse.go0
Rnurikabe/nurikabe_test.go → games/nurikabe/nurikabe_test.go0
Rnurikabe/print_adapter.go → games/nurikabe/print_adapter.go2+-
Rnurikabe/solver.go → games/nurikabe/solver.go0
Rnurikabe/style.go → games/nurikabe/style.go0
Rnurikabe/testdata/visual_states.jsonl → games/nurikabe/testdata/visual_states.jsonl0
Rrippleeffect/README.md → games/rippleeffect/README.md6+-
Rrippleeffect/export.go → games/rippleeffect/export.go0
Rrippleeffect/gamemode.go → games/rippleeffect/gamemode.go57++++++-----
Rrippleeffect/generator.go → games/rippleeffect/generator.go153++++++++++++++++++++++++++++--
Rrippleeffect/geometry.go → games/rippleeffect/geometry.go0
Rrippleeffect/grid.go → games/rippleeffect/grid.go0
Rrippleeffect/help.md → games/rippleeffect/help.md0
Rrippleeffect/keys.go → games/rippleeffect/keys.go0
Rrippleeffect/model.go → games/rippleeffect/model.go0
Rrippleeffect/print_adapter.go → games/rippleeffect/print_adapter.go2+-
Rrippleeffect/rippleeffect_test.go → games/rippleeffect/rippleeffect_test.go89+++++++++++++++++
Rrippleeffect/solver.go → games/rippleeffect/solver.go0
Rrippleeffect/style.go → games/rippleeffect/style.go0
Rrippleeffect/testdata/visual_states.jsonl → games/rippleeffect/testdata/visual_states.jsonl0
Rshikaku/README.md → games/shikaku/README.md0
Rshikaku/benchmark_test.go → games/shikaku/benchmark_test.go0
Rshikaku/export.go → games/shikaku/export.go0
Rshikaku/gamemode.go → games/shikaku/gamemode.go0
Rshikaku/generation_invariants_test.go → games/shikaku/generation_invariants_test.go0
Rshikaku/generator.go → games/shikaku/generator.go0
Rshikaku/help.md → games/shikaku/help.md0
Rshikaku/keys.go → games/shikaku/keys.go0
Rshikaku/model.go → games/shikaku/model.go0
Rshikaku/mouse.go → games/shikaku/mouse.go0
Rshikaku/print_adapter.go → games/shikaku/print_adapter.go2+-
Rshikaku/puzzle.go → games/shikaku/puzzle.go0
Rshikaku/shikaku_test.go → games/shikaku/shikaku_test.go0
Rshikaku/style.go → games/shikaku/style.go0
Rshikaku/testdata/visual_states.jsonl → games/shikaku/testdata/visual_states.jsonl0
Rspellpuzzle/README.md → games/spellpuzzle/README.md0
Rspellpuzzle/board.go → games/spellpuzzle/board.go0
Rspellpuzzle/export.go → games/spellpuzzle/export.go0
Rspellpuzzle/gamemode.go → games/spellpuzzle/gamemode.go0
Rspellpuzzle/generator.go → games/spellpuzzle/generator.go0
Rspellpuzzle/help.md → games/spellpuzzle/help.md0
Rspellpuzzle/keys.go → games/spellpuzzle/keys.go0
Rspellpuzzle/lexicon.go → games/spellpuzzle/lexicon.go0
Rspellpuzzle/model.go → games/spellpuzzle/model.go0
Rspellpuzzle/print_adapter.go → games/spellpuzzle/print_adapter.go2+-
Rspellpuzzle/print_adapter_test.go → games/spellpuzzle/print_adapter_test.go2+-
Rspellpuzzle/spellpuzzle_test.go → games/spellpuzzle/spellpuzzle_test.go0
Rspellpuzzle/style.go → games/spellpuzzle/style.go0
Rspellpuzzle/twl06.txt → games/spellpuzzle/twl06.txt0
Rsudoku/README.md → games/sudoku/README.md0
Rsudoku/benchmark_test.go → games/sudoku/benchmark_test.go0
Rsudoku/cell.go → games/sudoku/cell.go0
Rsudoku/dynamic_board_test.go → games/sudoku/dynamic_board_test.go0
Rsudoku/export.go → games/sudoku/export.go0
Rsudoku/gamemode.go → games/sudoku/gamemode.go0
Rsudoku/generator.go → games/sudoku/generator.go0
Rsudoku/help.md → games/sudoku/help.md0
Rsudoku/keys.go → games/sudoku/keys.go0
Rsudoku/model.go → games/sudoku/model.go0
Rsudoku/mouse.go → games/sudoku/mouse.go0
Rsudoku/print_adapter.go → games/sudoku/print_adapter.go2+-
Rsudoku/style.go → games/sudoku/style.go0
Rsudoku/sudoku_test.go → games/sudoku/sudoku_test.go0
Rsudoku/testdata/visual_states.jsonl → games/sudoku/testdata/visual_states.jsonl0
Rsudoku/uniqueness_test.go → games/sudoku/uniqueness_test.go0
Rsudokurgb/README.md → games/sudokurgb/README.md0
Rsudokurgb/analysis.go → games/sudokurgb/analysis.go0
Rsudokurgb/cell.go → games/sudokurgb/cell.go0
Rsudokurgb/dynamic_board_test.go → games/sudokurgb/dynamic_board_test.go0
Rsudokurgb/export.go → games/sudokurgb/export.go0
Rsudokurgb/gamemode.go → games/sudokurgb/gamemode.go0
Rsudokurgb/generator.go → games/sudokurgb/generator.go0
Rsudokurgb/help.md → games/sudokurgb/help.md0
Rsudokurgb/keys.go → games/sudokurgb/keys.go0
Rsudokurgb/model.go → games/sudokurgb/model.go0
Rsudokurgb/mouse.go → games/sudokurgb/mouse.go0
Rsudokurgb/print_adapter.go → games/sudokurgb/print_adapter.go2+-
Rsudokurgb/style.go → games/sudokurgb/style.go0
Rsudokurgb/sudokurgb_test.go → games/sudokurgb/sudokurgb_test.go0
Rsudokurgb/testdata/visual_states.jsonl → games/sudokurgb/testdata/visual_states.jsonl0
Rtakuzu/README.md → games/takuzu/README.md0
Rtakuzu/benchmark_test.go → games/takuzu/benchmark_test.go0
Rtakuzu/export.go → games/takuzu/export.go0
Rtakuzu/gamemode.go → games/takuzu/gamemode.go0
Rtakuzu/generator.go → games/takuzu/generator.go0
Rtakuzu/grid.go → games/takuzu/grid.go0
Rtakuzu/help.md → games/takuzu/help.md0
Rtakuzu/keys.go → games/takuzu/keys.go0
Rtakuzu/model.go → games/takuzu/model.go0
Rtakuzu/mouse.go → games/takuzu/mouse.go0
Rtakuzu/print_adapter.go → games/takuzu/print_adapter.go2+-
Rtakuzu/print_adapter_test.go → games/takuzu/print_adapter_test.go2+-
Rtakuzu/style.go → games/takuzu/style.go0
Rtakuzu/takuzu_test.go → games/takuzu/takuzu_test.go0
Rtakuzu/testdata/visual_states.jsonl → games/takuzu/testdata/visual_states.jsonl0
Rtakuzuplus/README.md → games/takuzuplus/README.md0
Rtakuzuplus/export.go → games/takuzuplus/export.go0
Rtakuzuplus/gamemode.go → games/takuzuplus/gamemode.go0
Rtakuzuplus/generator.go → games/takuzuplus/generator.go2+-
Rtakuzuplus/grid.go → games/takuzuplus/grid.go2+-
Rtakuzuplus/help.md → games/takuzuplus/help.md0
Rtakuzuplus/keys.go → games/takuzuplus/keys.go0
Rtakuzuplus/logic.go → games/takuzuplus/logic.go2+-
Rtakuzuplus/model.go → games/takuzuplus/model.go2+-
Rtakuzuplus/mouse.go → games/takuzuplus/mouse.go0
Rtakuzuplus/print_adapter.go → games/takuzuplus/print_adapter.go4+-
Rtakuzuplus/profile.go → games/takuzuplus/profile.go0
Rtakuzuplus/style.go → games/takuzuplus/style.go0
Rtakuzuplus/takuzuplus_test.go → games/takuzuplus/takuzuplus_test.go0
Rtakuzuplus/testdata/visual_states.jsonl → games/takuzuplus/testdata/visual_states.jsonl0
Rwordsearch/README.md → games/wordsearch/README.md0
Rwordsearch/benchmark_test.go → games/wordsearch/benchmark_test.go0
Rwordsearch/export.go → games/wordsearch/export.go0
Rwordsearch/gamemode.go → games/wordsearch/gamemode.go0
Rwordsearch/generator.go → games/wordsearch/generator.go0
Rwordsearch/grid.go → games/wordsearch/grid.go0
Rwordsearch/help.md → games/wordsearch/help.md0
Rwordsearch/keys.go → games/wordsearch/keys.go0
Rwordsearch/model.go → games/wordsearch/model.go0
Rwordsearch/mouse.go → games/wordsearch/mouse.go0
Rwordsearch/print_adapter.go → games/wordsearch/print_adapter.go2+-
Rwordsearch/style.go → games/wordsearch/style.go0
Rwordsearch/word.go → games/wordsearch/word.go0
Rwordsearch/words_dict.go → games/wordsearch/words_dict.go0
Rwordsearch/wordsearch_test.go → games/wordsearch/wordsearch_test.go0
Minternal/testjsonl/records.go2+-
Mregistry/registry.go30+++---
Mregistry/registry_test.go4+-
Mresolve/resolve_test.go2+-
Msession/session_test.go2+-
M app/export.go +77 -34
@@ -10,8 +10,8 @@ import (
1010 "strings"1111 1212 "github.com/FelineStateMachine/puzzletea/config"13+ "github.com/FelineStateMachine/puzzletea/export/pack"1314 puzzlegame "github.com/FelineStateMachine/puzzletea/game"14- "github.com/FelineStateMachine/puzzletea/packexport"1515 "github.com/FelineStateMachine/puzzletea/puzzle"1616 "github.com/FelineStateMachine/puzzletea/theme"1717 "github.com/FelineStateMachine/puzzletea/ui"
@@ -25,7 +25,6 @@ import (
2525 )2626 2727 type exportModel struct {28- initialized bool2928 focus exportFocus3029 values exportFormValues3130 titleInput textinput.Model
@@ -39,9 +38,6 @@ type exportModel struct {
3938 cardIndex int4039 bucketIndex int4140 cardRowOffset int42- running bool43- jobID int6444- cancel context.CancelFunc4541 width int4642 height int4743 }
@@ -199,6 +195,10 @@ type exportScreen struct {
199195 width int200196 height int201197 export exportModel198+ // job lifecycle — kept here so exportModel is pure form state199+ running bool200+ jobID int64201+ cancel context.CancelFunc202202 }203203 204204 func (s exportScreen) Resize(width, height int) screenModel {
@@ -254,20 +254,37 @@ func (s exportRunningScreen) View(notice noticeState) string {
254254 return ui.CenterView(s.width, s.height, box)255255 }256256 257-func (m model) handleExportEnter() (tea.Model, tea.Cmd) {258- if !m.export.initialized {257+func (m model) activeExportScreen() (exportScreen, bool) {258+ s, ok := m.screens[exportView].(exportScreen)259+ return s, ok260+}261+262+func (m model) handleExportEnter() (model, tea.Cmd) {263+ if m.screens == nil {264+ m.screens = make(map[viewState]screenModel)265+ }266+ if _, exists := m.screens[exportView]; !exists {259267 spec := m.initialExportSpec()260- m.export = buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width)268+ es := exportScreen{269+ width: m.width,270+ height: m.height,271+ export: buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width),272+ }273+ m.screens[exportView] = es.Resize(m.width, m.height)261274 } else {262- m.export.resize(m.width)275+ m.screens[exportView] = m.screens[exportView].Resize(m.width, m.height)263276 }264277 m.state = exportView265278 m = m.clearNotice()266- return m.initScreen(exportView), nil279+ return m, nil267280 }268281 269-func (m model) handleExportSubmit() (tea.Model, tea.Cmd) {270- spec, cfg := m.export.toSpecAndConfig()282+func (m model) handleExportSubmit() (model, tea.Cmd) {283+ es, ok := m.activeExportScreen()284+ if !ok {285+ return m, nil286+ }287+ spec, cfg := es.export.toSpecAndConfig()271288 if err := packexport.ValidateSpec(spec); err != nil {272289 return m.setErrorf("Could not start export: %v", err), nil273290 }
@@ -281,17 +298,18 @@ func (m model) handleExportSubmit() (tea.Model, tea.Cmd) {
281298 return m, cmd282299 }283300 284-func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd) {285- if msg.jobID != m.export.jobID {301+func (m model) handleExportComplete(msg exportCompleteMsg) (model, tea.Cmd) {302+ es, ok := m.activeExportScreen()303+ if !ok || msg.jobID != es.jobID {286304 return m, nil287305 }288306 289- m.export.running = false290- if m.export.cancel != nil {291- m.export.cancel()292- m.export.cancel = nil307+ if es.cancel != nil {308+ es.cancel()309+ es.cancel = nil293310 }294-311+ es.running = false312+ m.screens[exportView] = es295313 m.state = exportView296314 297315 if msg.err != nil {
@@ -306,23 +324,30 @@ func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd)
306324 }307325 308326 func (m *model) cancelActiveExport() {309- if m.export.cancel != nil {310- m.export.cancel()311- m.export.cancel = nil327+ es, ok := m.screens[exportView].(exportScreen)328+ if !ok {329+ return312330 }313- if m.export.running {314- m.export.jobID++331+ if es.cancel != nil {332+ es.cancel()333+ es.cancel = nil315334 }316- m.export.running = false335+ if es.running {336+ es.jobID++337+ }338+ es.running = false339+ m.screens[exportView] = es317340 }318341 319342 func (m *model) startExport(spec packexport.Spec) tea.Cmd {320343 m.cancelActiveExport()321- m.export.jobID++322- jobID := m.export.jobID344+ es, _ := m.screens[exportView].(exportScreen)345+ es.jobID++346+ jobID := es.jobID323347 ctx, cancel := context.WithCancel(context.Background())324- m.export.cancel = cancel325- m.export.running = true348+ es.cancel = cancel349+ es.running = true350+ m.screens[exportView] = es326351 m.state = exportRunningView327352 *m = m.clearNotice()328353 *m = m.initScreen(exportRunningView)
@@ -339,11 +364,10 @@ func exportCmd(ctx context.Context, jobID int64, spec packexport.Spec) tea.Cmd {
339364 func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportModel {340365 contentWidth, _ := exportContentBounds(width, 0)341366 state := exportModel{342- initialized: true,343- focus: exportFocusCards,344- values: values,345- cards: cards,346- width: width,367+ focus: exportFocusCards,368+ values: values,369+ cards: cards,370+ width: width,347371 }348372 state.initInputs(contentWidth)349373 return state
@@ -588,6 +612,12 @@ func (s *exportModel) moveFocus(delta int) tea.Cmd {
588612 next += len(focuses)589613 }590614 s.focus = focuses[next]615+ switch s.focus {616+ case exportFocusPDFPath:617+ s.pdfPathInput.SetCursor(runeIdxBeforeExt(s.values.PDFOutputPath))618+ case exportFocusJSONLPath:619+ s.jsonlPathInput.SetCursor(runeIdxBeforeExt(s.values.JSONLOutputPath))620+ }591621 return s.applyFocus()592622 }593623
@@ -1675,3 +1705,16 @@ func expandCardCounts(cards []exportGameCard) map[puzzle.GameID]map[puzzle.ModeI
16751705 }16761706 return counts16771707 }1708+1709+// runeIdxBeforeExt returns the rune index just before the file extension so1710+// that focusing a path field via tab lands the cursor there. This lets users1711+// type a new base-name suffix (e.g. "2") without overwriting ".pdf"/".jsonl".1712+func runeIdxBeforeExt(path string) int {1713+ ext := filepath.Ext(path)1714+ runes := []rune(path)1715+ extRunes := len([]rune(ext))1716+ if extRunes == 0 || extRunes >= len(runes) {1717+ return len(runes)1718+ }1719+ return len(runes) - extRunes1720+}
M app/export_test.go +33 -35
@@ -8,7 +8,7 @@ import (
88 tea "charm.land/bubbletea/v2"99 "charm.land/lipgloss/v2"1010 "github.com/FelineStateMachine/puzzletea/config"11- "github.com/FelineStateMachine/puzzletea/packexport"11+ "github.com/FelineStateMachine/puzzletea/export/pack"1212 "github.com/FelineStateMachine/puzzletea/puzzle"1313 )1414
@@ -34,24 +34,24 @@ func TestHandleExportEnterLoadsPersistedDefaults(t *testing.T) {
3434 height: 30,3535 }3636 37- next, _ := m.handleExportEnter()38- got := next.(model)37+ got, _ := m.handleExportEnter()3938 39+ es, ok := got.screens[exportView].(exportScreen)40+ if !ok {41+ t.Fatal("expected export screen to be initialized")42+ }4043 if got.state != exportView {4144 t.Fatalf("state = %d, want %d", got.state, exportView)4245 }43- if got.export.values.Title != "Saved Sampler" {44- t.Fatalf("title = %q, want %q", got.export.values.Title, "Saved Sampler")46+ if es.export.values.Title != "Saved Sampler" {47+ t.Fatalf("title = %q, want %q", es.export.values.Title, "Saved Sampler")4548 }46- if got.export.values.Volume != "3" {47- t.Fatalf("volume = %q, want %q", got.export.values.Volume, "3")49+ if es.export.values.Volume != "3" {50+ t.Fatalf("volume = %q, want %q", es.export.values.Volume, "3")4851 }49- if got.export.cards[findExportCardIndex(got.export.cards, puzzle.CanonicalGameID("Sudoku"))].Buckets[0] != 4 {52+ if es.export.cards[findExportCardIndex(es.export.cards, puzzle.CanonicalGameID("Sudoku"))].Buckets[0] != 4 {5053 t.Fatal("expected persisted count to populate the form")5154 }52- if !got.export.initialized {53- t.Fatal("expected export editor to be initialized")54- }5555 }5656 5757 func TestHandleExportCompleteSuccessShowsSuccessNotice(t *testing.T) {
@@ -62,22 +62,21 @@ func TestHandleExportCompleteSuccessShowsSuccessNotice(t *testing.T) {
6262 state: exportRunningView,6363 width: 100,6464 height: 30,65- export: exportModel{66- jobID: 7,67- initialized: true,68- values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),69- cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),65+ screens: map[viewState]screenModel{66+ exportView: exportScreen{67+ jobID: 7,68+ export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},69+ },7070 },7171 }7272 73- next, _ := m.handleExportComplete(exportCompleteMsg{73+ got, _ := m.handleExportComplete(exportCompleteMsg{7474 jobID: 7,7575 result: packexport.Result{7676 TotalCount: 10,7777 PDFOutputPath: filepath.Join(tmp, "sample.pdf"),7878 },7979 })80- got := next.(model)8180 8281 if got.state != exportView {8382 t.Fatalf("state = %d, want %d", got.state, exportView)
@@ -102,15 +101,15 @@ func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {
102101 state: exportRunningView,103102 width: 100,104103 height: 30,105- export: exportModel{106- jobID: 8,107- initialized: true,108- values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),109- cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),104+ screens: map[viewState]screenModel{105+ exportView: exportScreen{106+ jobID: 8,107+ export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},108+ },110109 },111110 }112111 113- next, _ := m.handleExportComplete(exportCompleteMsg{112+ got, _ := m.handleExportComplete(exportCompleteMsg{114113 jobID: 8,115114 result: packexport.Result{116115 TotalCount: 10,
@@ -118,7 +117,6 @@ func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {
118117 JSONLOutputPath: jsonlPath,119118 },120119 })121- got := next.(model)122120 123121 if !strings.Contains(got.notice.message, jsonlPath) {124122 t.Fatalf("notice message = %q, want jsonl output path", got.notice.message)
@@ -133,19 +131,18 @@ func TestHandleExportCompleteFailureShowsErrorNotice(t *testing.T) {
133131 state: exportRunningView,134132 width: 100,135133 height: 30,136- export: exportModel{137- jobID: 9,138- initialized: true,139- values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),140- cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),134+ screens: map[viewState]screenModel{135+ exportView: exportScreen{136+ jobID: 9,137+ export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},138+ },141139 },142140 }143141 144- next, _ := m.handleExportComplete(exportCompleteMsg{142+ got, _ := m.handleExportComplete(exportCompleteMsg{145143 jobID: 9,146144 err: assertiveError("boom"),147145 })148- got := next.(model)149146 150147 if got.notice.level != noticeLevelError {151148 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)
@@ -165,11 +162,12 @@ func TestHandleExportSubmitRejectsInvalidVolumeInput(t *testing.T) {
165162 state: exportView,166163 width: 100,167164 height: 30,168- export: state,165+ screens: map[viewState]screenModel{166+ exportView: exportScreen{export: state, width: 100, height: 30},167+ },169168 }170169 171- next, _ := m.handleExportSubmit()172- got := next.(model)170+ got, _ := m.handleExportSubmit()173171 174172 if got.state != exportView {175173 t.Fatalf("state = %d, want %d", got.state, exportView)
M app/handle_daily.go +1 -1
M app/handle_game.go +0 -51
@@ -1,56 +1,5 @@
11 package app22 3-import (4- "github.com/FelineStateMachine/puzzletea/registry"5- sessionflow "github.com/FelineStateMachine/puzzletea/session"6- "github.com/FelineStateMachine/puzzletea/store"7- "github.com/FelineStateMachine/puzzletea/ui"8-9- tea "charm.land/bubbletea/v2"10-)11-12-func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {13- entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())14- if !ok {15- return m, nil16- }17- m.nav.selectedCategory = entry18- m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")19- m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))20- m.state = modeSelectView21- m = m.initScreen(modeSelectView)22- return m, nil23-}24-25-func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {26- item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())27- mode, ok := item.(registry.ModeEntry)28- if !ok {29- return m, nil30- }31- m.nav.selectedModeTitle = mode.Definition.Title32- cmd := newSessionController(&m).startSpawn(mode.Spawner, spawnRequest{33- source: spawnSourceNormal,34- name: sessionflow.GenerateUniqueName(m.store),35- gameType: m.nav.selectedCategory.Definition.Name,36- modeTitle: m.nav.selectedModeTitle,37- run: store.NormalRunMetadata(),38- returnState: modeSelectView,39- exitState: mainMenuView,40- })41- return m, cmd42-}43-44-func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {45- idx := m.cont.table.Cursor()46- if idx < 0 || idx >= len(m.cont.games) {47- return m, nil48- }49- rec := m.cont.games[idx]50- m, _ = m.importAndActivateRecord(rec)51- return m, nil52-}53-543 func (m model) persistCompletionIfSolved() model {554 newSessionController(&m).persistCompletionIfSolved()565 return m
M app/handle_help.go +2 -4
M app/handle_seed.go +1 -1
M app/handle_stats.go +1 -1
M app/handle_theme.go +2 -2
M app/help_layout_test.go +4 -4
M app/model.go +0 -1
M app/notice.go +0 -4
M app/screen_core.go +61 -49
@@ -3,6 +3,9 @@ package app
33 import (44 "time"55 6+ "github.com/FelineStateMachine/puzzletea/registry"7+ sessionflow "github.com/FelineStateMachine/puzzletea/session"8+ "github.com/FelineStateMachine/puzzletea/store"69 "github.com/FelineStateMachine/puzzletea/theme"710 "github.com/FelineStateMachine/puzzletea/ui"811
@@ -25,7 +28,7 @@ func (a openPlayMenuAction) applyToModel(m model) (model, tea.Cmd) {
2528 type openStatsAction struct{}2629 2730 func (a openStatsAction) applyToModel(m model) (model, tea.Cmd) {28- return asModel(m.handleStatsEnter())31+ return m.handleStatsEnter()2932 }3033 3134 type openOptionsMenuAction struct{}
@@ -64,19 +67,19 @@ func (a openContinueAction) applyToModel(m model) (model, tea.Cmd) {
6467 type openDailyAction struct{}6568 6669 func (a openDailyAction) applyToModel(m model) (model, tea.Cmd) {67- return asModel(m.handleDailyPuzzle())70+ return m.handleDailyPuzzle()6871 }6972 7073 type openExportAction struct{}7174 7275 func (a openExportAction) applyToModel(m model) (model, tea.Cmd) {73- return asModel(m.handleExportEnter())76+ return m.handleExportEnter()7477 }7578 7679 type openWeeklyAction struct{}7780 7881 func (a openWeeklyAction) applyToModel(m model) (model, tea.Cmd) {79- return asModel(m.enterWeeklyView())82+ return m.enterWeeklyView()8083 }8184 8285 type openSeedInputAction struct{}
@@ -101,70 +104,82 @@ func (a backAction) applyToModel(m model) (model, tea.Cmd) {
101104 return m.resizeActiveScreen(), nil102105 }103106 104-type gameSelectEnterAction struct{}107+type gameSelectEnterAction struct {108+ entry registry.Entry109+}105110 106111 func (a gameSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {107- if gs, ok := m.screens[gameSelectView].(gameSelectScreen); ok {108- m.nav.gameSelectList = gs.list109- m.nav.categoryDetail = gs.detail110- }111- return asModel(m.handleGameSelectEnter())112+ m.nav.selectedCategory = a.entry113+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(a.entry), a.entry.Definition.Name+" - Select Mode")114+ m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))115+ m.state = modeSelectView116+ m = m.initScreen(modeSelectView)117+ return m, nil112118 }113119 114-type modeSelectEnterAction struct{}120+type modeSelectEnterAction struct {121+ entry registry.Entry122+ mode registry.ModeEntry123+}115124 116125 func (a modeSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {117- if ms, ok := m.screens[modeSelectView].(modeSelectScreen); ok {118- m.nav.modeSelectList = ms.list119- m.nav.selectedCategory = ms.entry120- }121- return asModel(m.handleModeSelectEnter())126+ m.nav.selectedCategory = a.entry127+ m.nav.selectedModeTitle = a.mode.Definition.Title128+ cmd := newSessionController(&m).startSpawn(a.mode.Spawner, spawnRequest{129+ source: spawnSourceNormal,130+ name: sessionflow.GenerateUniqueName(m.store),131+ gameType: a.entry.Definition.Name,132+ modeTitle: a.mode.Definition.Title,133+ run: store.NormalRunMetadata(),134+ returnState: modeSelectView,135+ exitState: mainMenuView,136+ })137+ return m, cmd122138 }123139 124-type continueEnterAction struct{}140+type continueEnterAction struct {141+ record store.GameRecord142+}125143 126144 func (a continueEnterAction) applyToModel(m model) (model, tea.Cmd) {127- if cs, ok := m.screens[continueView].(continueScreen); ok {128- m.cont = cs.cont129- }130- return asModel(m.handleContinueEnter())145+ m, _ = m.importAndActivateRecord(a.record)146+ return m, nil131147 }132148 133149 type weeklyShiftAction struct {134- delta int150+ delta int151+ weekly weeklyState135152 }136153 137154 func (a weeklyShiftAction) applyToModel(m model) (model, tea.Cmd) {138- if ws, ok := m.screens[weeklyView].(weeklyScreen); ok {139- m.weekly = ws.weekly140- }155+ m.weekly = a.weekly141156 m = m.moveWeeklyWeek(a.delta)142157 m = m.initScreen(weeklyView)143158 return m, nil144159 }145160 146-type weeklyEnterAction struct{}161+type weeklyEnterAction struct {162+ weekly weeklyState163+}147164 148165 func (a weeklyEnterAction) applyToModel(m model) (model, tea.Cmd) {149- if ws, ok := m.screens[weeklyView].(weeklyScreen); ok {150- m.weekly = ws.weekly151- }152- return asModel(m.handleWeeklyEnter())166+ m.weekly = a.weekly167+ return m.handleWeeklyEnter()153168 }154169 155-type helpSelectEnterAction struct{}170+type helpSelectEnterAction struct {171+ entry registry.Entry172+}156173 157174 func (a helpSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {158- if hs, ok := m.screens[helpSelectView].(helpSelectScreen); ok {159- m.help.selectList = hs.help.selectList160- }161- return asModel(m.handleHelpSelectEnter())175+ m.help.category = a.entry176+ return m.handleHelpSelectEnter()162177 }163178 164179 type openThemeSelectAction struct{}165180 166181 func (a openThemeSelectAction) applyToModel(m model) (model, tea.Cmd) {167- return asModel(m.handleThemeEnter())182+ return m.handleThemeEnter()168183 }169184 170185 type openHelpSelectAction struct{}
@@ -189,22 +204,22 @@ func (a previewThemeAction) applyToModel(m model) (model, tea.Cmd) {
189204 return m, nil190205 }191206 192-type confirmThemeAction struct{}207+type confirmThemeAction struct {208+ theme themeState209+}193210 194211 func (a confirmThemeAction) applyToModel(m model) (model, tea.Cmd) {195- if ts, ok := m.screens[themeSelectView].(themeSelectScreen); ok {196- m.theme = ts.theme197- }198- return asModel(m.handleThemeConfirm())212+ m.theme = a.theme213+ return m.handleThemeConfirm()199214 }200215 201-type seedConfirmAction struct{}216+type seedConfirmAction struct {217+ seed seedState218+}202219 203220 func (a seedConfirmAction) applyToModel(m model) (model, tea.Cmd) {204- if si, ok := m.screens[seedInputView].(seedInputScreen); ok {205- m.seed = si.seed206- }207- return asModel(m.handleSeedConfirm())221+ m.seed = a.seed222+ return m.handleSeedConfirm()208223 }209224 210225 // exportSubmitAction is a pure tea.Msg emitted by exportSubmitCmd.
@@ -238,9 +253,6 @@ var screenRegistry = map[viewState]screenFactory{
238253 modeSelectView: func(m model) screenModel {239254 return modeSelectScreen{width: m.width, height: m.height, entry: m.nav.selectedCategory, list: m.nav.modeSelectList}240255 },241- exportView: func(m model) screenModel {242- return exportScreen{width: m.width, height: m.height, export: m.export}243- },244256 continueView: func(m model) screenModel {245257 return continueScreen{width: m.width, height: m.height, cont: m.cont}246258 },
M app/screen_navigation.go +28 -51
@@ -30,7 +30,7 @@ func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction
3030 switch {3131 case key.Matches(keyMsg, rootKeys.Enter):3232 if sessionflow.NormalizeSeed(s.seed.input.Value()) != "" {33- return s, nil, seedConfirmAction{}33+ return s, nil, seedConfirmAction{seed: s.seed}3434 }3535 return s, nil, nil3636 case key.Matches(keyMsg, rootKeys.Escape):
@@ -45,15 +45,9 @@ func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction
4545 }4646 4747 func (s seedInputScreen) View(notice noticeState) string {48- m := model{49- width: s.width,50- height: s.height,51- seed: s.seed,52- notice: notice,53- }54- return m.renderPanel(48+ return renderPanelView(s.width, s.height, notice,5549 "Enter Seed",56- m.seedInputBody(),50+ seedInputBody(s.seed),5751 "↑/↓ change field • ←/→ game • enter confirm • esc back",5852 )5953 }
@@ -95,7 +89,11 @@ func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
9589 case s.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):9690 case s.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):9791 case key.Matches(keyMsg, rootKeys.Enter):98- return s, nil, gameSelectEnterAction{}92+ entry, ok := selectedCategoryEntry(s.list.SelectedItem())93+ if !ok {94+ return s, nil, nil95+ }96+ return s, nil, gameSelectEnterAction{entry: entry}9997 case key.Matches(keyMsg, rootKeys.Escape):10098 return s, nil, backAction{target: playMenuView}10199 case keyMsg.String() == "pgup":
@@ -117,16 +115,7 @@ func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
117115 }118116 119117 func (s gameSelectScreen) View(notice noticeState) string {120- m := model{121- width: s.width,122- height: s.height,123- notice: notice,124- nav: navigationState{125- gameSelectList: s.list,126- categoryDetail: s.detail,127- },128- }129- return m.gameSelectViewContent()118+ return gameSelectViewContent(s.width, s.height, s.list, s.detail, notice)130119 }131120 132121 func (s gameSelectScreen) resizeSelf() gameSelectScreen {
@@ -152,7 +141,12 @@ func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
152141 if ok {153142 switch {154143 case key.Matches(keyMsg, rootKeys.Enter):155- return s, nil, modeSelectEnterAction{}144+ item := unwrapModeDisplayItem(s.list.SelectedItem())145+ mode, ok := item.(registry.ModeEntry)146+ if !ok {147+ return s, nil, nil148+ }149+ return s, nil, modeSelectEnterAction{entry: s.entry, mode: mode}156150 case key.Matches(keyMsg, rootKeys.Escape):157151 return s, nil, backAction{target: gameSelectView}158152 }
@@ -164,18 +158,9 @@ func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
164158 }165159 166160 func (s modeSelectScreen) View(notice noticeState) string {167- m := model{168- width: s.width,169- height: s.height,170- notice: notice,171- nav: navigationState{172- selectedCategory: s.entry,173- modeSelectList: s.list,174- },175- }176- return m.renderPanel(177- m.nav.selectedCategory.Definition.Name+" — Select Mode",178- m.nav.modeSelectList.View(),161+ return renderPanelView(s.width, s.height, notice,162+ s.entry.Definition.Name+" — Select Mode",163+ s.list.View(),179164 "↑/↓ navigate • enter select • esc back",180165 )181166 }
@@ -200,7 +185,11 @@ func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)
200185 if ok {201186 switch {202187 case key.Matches(keyMsg, rootKeys.Enter):203- return s, nil, continueEnterAction{}188+ idx := s.cont.table.Cursor()189+ if idx >= 0 && idx < len(s.cont.games) {190+ return s, nil, continueEnterAction{record: s.cont.games[idx]}191+ }192+ return s, nil, nil204193 case key.Matches(keyMsg, rootKeys.Escape):205194 return s, nil, backAction{target: playMenuView}206195 }
@@ -212,13 +201,7 @@ func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)
212201 }213202 214203 func (s continueScreen) View(notice noticeState) string {215- m := model{216- width: s.width,217- height: s.height,218- notice: notice,219- cont: s.cont,220- }221- return m.renderContinueView()204+ return renderContinueView(s.cont, s.width, s.height, notice)222205 }223206 224207 type weeklyScreen struct {
@@ -243,11 +226,11 @@ func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
243226 case key.Matches(keyMsg, rootKeys.Escape):244227 return s, nil, backAction{target: playMenuView}245228 case key.Matches(keyMsg, rootKeys.Enter):246- return s, nil, weeklyEnterAction{}229+ return s, nil, weeklyEnterAction{weekly: s.weekly}247230 case keyMsg.String() == "left" || keyMsg.String() == "h":248- return s, nil, weeklyShiftAction{delta: -1}231+ return s, nil, weeklyShiftAction{delta: -1, weekly: s.weekly}249232 case keyMsg.String() == "right" || keyMsg.String() == "l":250- return s, nil, weeklyShiftAction{delta: 1}233+ return s, nil, weeklyShiftAction{delta: 1, weekly: s.weekly}251234 }252235 }253236
@@ -257,11 +240,5 @@ func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
257240 }258241 259242 func (s weeklyScreen) View(notice noticeState) string {260- m := model{261- width: s.width,262- height: s.height,263- notice: notice,264- weekly: s.weekly,265- }266- return centerContentWithNotice(s.width, s.height, notice, m.weeklyViewContent())243+ return centerContentWithNotice(s.width, s.height, notice, weeklyViewContent(s.weekly))267244 }
M app/screen_support.go +10 -33
@@ -29,7 +29,8 @@ func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
2929 if ok {3030 switch {3131 case key.Matches(keyMsg, rootKeys.Enter):32- return s, nil, helpSelectEnterAction{}32+ entry, _ := selectedCategoryEntry(s.help.selectList.SelectedItem())33+ return s, nil, helpSelectEnterAction{entry: entry}3334 case key.Matches(keyMsg, rootKeys.Escape):3435 return s, nil, backAction{target: optionsMenuView}3536 }
@@ -41,15 +42,9 @@ func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
4142 }4243 4344 func (s helpSelectScreen) View(notice noticeState) string {44- m := model{45- width: s.width,46- height: s.height,47- notice: notice,48- help: s.help,49- }50- return m.renderPanel(45+ return renderPanelView(s.width, s.height, notice,5146 "How to Play",52- m.help.selectList.View(),47+ s.help.selectList.View(),5348 "↑/↓ navigate • enter select • esc back",5449 )5550 }
@@ -85,15 +80,9 @@ func (s helpDetailScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
8580 }8681 8782 func (s helpDetailScreen) View(notice noticeState) string {88- m := model{89- width: s.width,90- height: s.height,91- notice: notice,92- help: s.help,93- }94- return m.renderPanel(95- m.help.category.Definition.Name+" — Guide",96- m.help.viewport.View(),83+ return renderPanelView(s.width, s.height, notice,84+ s.help.category.Definition.Name+" — Guide",85+ s.help.viewport.View(),9786 "↑/↓ scroll • esc back",9887 )9988 }
@@ -129,13 +118,7 @@ func (s statsScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
129118 }130119 131120 func (s statsScreen) View(notice noticeState) string {132- m := model{133- width: s.width,134- height: s.height,135- notice: notice,136- stats: s.stats,137- }138- return m.renderStatsView()121+ return renderStatsView(s.stats, s.width, s.height, notice)139122 }140123 141124 type themeSelectScreen struct {
@@ -164,7 +147,7 @@ func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActi
164147 case s.theme.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):165148 case s.theme.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):166149 case key.Matches(keyMsg, rootKeys.Enter):167- return s, nil, confirmThemeAction{}150+ return s, nil, confirmThemeAction{theme: s.theme}168151 case key.Matches(keyMsg, rootKeys.Escape):169152 return s, nil, backAction{target: optionsMenuView}170153 }
@@ -182,13 +165,7 @@ func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActi
182165 }183166 184167 func (s themeSelectScreen) View(notice noticeState) string {185- m := model{186- width: s.width,187- height: s.height,188- notice: notice,189- theme: s.theme,190- }191- return m.themeSelectViewContent()168+ return themeSelectViewContent(s.width, s.height, s.theme, notice)192169 }193170 194171 type generatingScreen struct {
M app/seed_input.go +15 -11
@@ -87,13 +87,17 @@ func (m model) enterSeedInputView() (model, tea.Cmd) {
8787 }8888 8989 func (m model) currentSeedMode() seedModeOption {90- if len(m.seed.modeOptions) == 0 {90+ return currentSeedMode(m.seed)91+}92+93+func currentSeedMode(seed seedState) seedModeOption {94+ if len(seed.modeOptions) == 0 {9195 return seedModeOption{label: randomSeedModeLabel}9296 }93- if m.seed.modeIndex < 0 || m.seed.modeIndex >= len(m.seed.modeOptions) {94- return m.seed.modeOptions[0]97+ if seed.modeIndex < 0 || seed.modeIndex >= len(seed.modeOptions) {98+ return seed.modeOptions[0]9599 }96- return m.seed.modeOptions[m.seed.modeIndex]100+ return seed.modeOptions[seed.modeIndex]97101 }98102 99103 func (m model) moveSeedMode(step int) model {
@@ -147,21 +151,21 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {
147151 return m, cmd148152 }149153 150-func (m model) seedInputBody() string {151- selector := renderSeedModeTitle(m.currentSeedMode().label, m.seed.modeIndex)154+func seedInputBody(seed seedState) string {155+ selector := renderSeedModeTitle(currentSeedMode(seed).label, seed.modeIndex)152156 modePrefix := " "153- if m.seed.focus == seedFocusMode {157+ if seed.focus == seedFocusMode {154158 modePrefix = ui.CursorStyle().Render("> ")155159 }156160 157- seedInputView := m.seed.input.View()158- if m.seed.focus != seedFocusText {159- seedInputView = strings.Replace(seedInputView, ">", " ", 1)161+ inputView := seed.input.View()162+ if seed.focus != seedFocusText {163+ inputView = strings.Replace(inputView, ">", " ", 1)160164 }161165 162166 return lipgloss.JoinVertical(163167 lipgloss.Left,164- seedInputView,168+ inputView,165169 "",166170 modePrefix+selector,167171 )
M app/update.go +12 -6
M app/update_test.go +4 -7
@@ -11,7 +11,7 @@ import (
1111 tea "charm.land/bubbletea/v2"1212 "github.com/FelineStateMachine/puzzletea/daily"1313 "github.com/FelineStateMachine/puzzletea/game"14- "github.com/FelineStateMachine/puzzletea/lightsout"14+ "github.com/FelineStateMachine/puzzletea/games/lightsout"1515 "github.com/FelineStateMachine/puzzletea/registry"1616 sessionflow "github.com/FelineStateMachine/puzzletea/session"1717 "github.com/FelineStateMachine/puzzletea/store"
@@ -208,8 +208,7 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
208208 seed: seedState{input: ti},209209 }210210 211- next, _ := m.handleSeedConfirm()212- got := next.(model)211+ got, _ := m.handleSeedConfirm()213212 if got.state != seedInputView {214213 t.Fatalf("state = %d, want %d (seedInputView)", got.state, seedInputView)215214 }
@@ -288,8 +287,7 @@ func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {
288287 },289288 }290289 291- next, _ := m.handleSeedConfirm()292- got := next.(model)290+ got, _ := m.handleSeedConfirm()293291 294292 if got.state != generatingView {295293 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)
@@ -345,8 +343,7 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {
345343 store: s,346344 }347345 348- next, _ := m.handleDailyPuzzle()349- got := next.(model)346+ got, _ := m.handleDailyPuzzle()350347 if got.state != playMenuView {351348 t.Fatalf("state = %d, want %d (playMenuView)", got.state, playMenuView)352349 }
M app/view.go +22 -20
@@ -8,6 +8,8 @@ import (
88 "github.com/FelineStateMachine/puzzletea/theme"99 "github.com/FelineStateMachine/puzzletea/ui"1010 11+ "charm.land/bubbles/v2/list"12+ "charm.land/bubbles/v2/viewport"1113 tea "charm.land/bubbletea/v2"1214 "charm.land/lipgloss/v2"1315 )
@@ -34,21 +36,21 @@ func (m model) viewContent() string {
3436 return screen.View(m.notice)3537 }3638 37-func (m model) gameSelectViewContent() string {38- metrics := categoryPickerSize(m.width, m.height)39+func gameSelectViewContent(width, height int, gameList list.Model, detail viewport.Model, notice noticeState) string {40+ metrics := categoryPickerSize(width, height)3941 4042 listView := lipgloss.NewStyle().4143 Width(metrics.listWidth).4244 Height(metrics.listHeight).4345 Border(lipgloss.NormalBorder(), false, true, false, false).4446 BorderForeground(theme.Current().Border).45- Render(m.nav.gameSelectList.View())47+ Render(gameList.View())4648 4749 detailBox := lipgloss.NewStyle().4850 Width(metrics.detailWidth).4951 Height(metrics.detailHeight).5052 Padding(2, 3).51- Render(m.nav.categoryDetail.View())53+ Render(detail.View())5254 5355 body := lipgloss.JoinHorizontal(lipgloss.Top, listView, strings.Repeat(" ", categoryGapWidth), detailBox)5456 if metrics.stacked {
@@ -61,17 +63,17 @@ func (m model) gameSelectViewContent() string {
6163 6264 panel := ui.Panel(6365 "Select Category",64- m.appendNotice(body),66+ appendNoticeContent(width, notice, body),6567 "↑/↓ navigate • / filter • enter select • esc back",6668 )67- return ui.CenterView(m.width, m.height, panel)69+ return ui.CenterView(width, height, panel)6870 }6971 70-func (m model) weeklyViewContent() string {71- title := "Weekly Gauntlet — " + m.weeklyPanelTitle()72- if len(m.weekly.rows) == 0 {72+func weeklyViewContent(w weeklyState) string {73+ title := "Weekly Gauntlet — " + weeklyPanelTitle(w.cursor)74+ if len(w.rows) == 0 {7375 body := "No completed puzzles for this week yet."74- if m.isCurrentWeeklySelection() {76+ if isCurrentWeeklySelection(w.cursor) {7577 body = "No weekly puzzles are available."7678 }7779 return ui.Panel(
@@ -82,15 +84,15 @@ func (m model) weeklyViewContent() string {
8284 }8385 8486 footer := "←/→ week • enter open • esc back"85- if !m.isCurrentWeeklySelection() {87+ if !isCurrentWeeklySelection(w.cursor) {8688 footer = "←/→ week • enter review • esc back"8789 }88- if pg := ui.TablePagination(m.weekly.table); pg != "" {90+ if pg := ui.TablePagination(w.table); pg != "" {8991 footer = pg + " " + footer9092 }9193 92- description := m.weekly.table.View()93- if !m.isCurrentWeeklySelection() {94+ description := w.table.View()95+ if !isCurrentWeeklySelection(w.cursor) {9496 description = lipgloss.JoinVertical(9597 lipgloss.Left,9698 ui.DimItemStyle().Render("Review only: completed puzzles from this week."),
@@ -179,19 +181,19 @@ func renderModeList(entry registry.Entry, width int) string {
179181 return strings.Join(lines, "\n")180182 }181183 182-func (m model) themeSelectViewContent() string {184+func themeSelectViewContent(width, height int, thm themeState, notice noticeState) string {183185 p := theme.Current()184186 185187 themeName := theme.DefaultThemeName186- if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {188+ if item, ok := thm.list.SelectedItem().(ui.MenuItem); ok {187189 themeName = item.ItemTitle188190 }189191 190192 const panelChrome = 8191- innerH := m.height - panelChrome193+ innerH := height - panelChrome192194 innerH = max(innerH, 10)193195 194- listView := m.theme.list.View()196+ listView := thm.list.View()195197 196198 previewBorder := lipgloss.NewStyle().197199 Border(lipgloss.RoundedBorder()).
@@ -205,8 +207,8 @@ func (m model) themeSelectViewContent() string {
205207 206208 panel := ui.Panel(207209 "Select Theme",208- m.appendNotice(body),210+ appendNoticeContent(width, notice, body),209211 "↑/↓ navigate • / filter • enter select • esc back",210212 )211- return ui.CenterView(m.width, m.height, panel)213+ return ui.CenterView(width, height, panel)212214 }
M app/view_helpers.go +12 -12
@@ -6,16 +6,16 @@ import (
66 "charm.land/lipgloss/v2"77 )88 9-func (m model) renderContinueView() string {10- if len(m.cont.games) == 0 {11- return m.renderPanel("Saved Games", "No saved games yet.", "esc back")9+func renderContinueView(cont continueState, width, height int, notice noticeState) string {10+ if len(cont.games) == 0 {11+ return renderPanelView(width, height, notice, "Saved Games", "No saved games yet.", "esc back")1212 }1313 1414 footer := "↑/↓ navigate • enter resume • esc back"15- if pg := ui.TablePagination(m.cont.table); pg != "" {15+ if pg := ui.TablePagination(cont.table); pg != "" {1616 footer = pg + " " + footer1717 }18- return m.renderPanel("Saved Games", m.cont.table.View(), footer)18+ return renderPanelView(width, height, notice, "Saved Games", cont.table.View(), footer)1919 }2020 2121 func (m model) renderGameView() string {
@@ -34,19 +34,19 @@ func (m model) renderGameView() string {
3434 return ui.CenterView(m.width, m.height, m.appendNotice(centered))3535 }3636 37-func (m model) renderStatsView() string {38- statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards)37+func renderStatsView(stats statsState, width, height int, notice noticeState) string {38+ statsWidth, _ := statsViewportSize(width, height, stats.cards)3939 var statsBody string40- if len(m.stats.cards) == 0 {41- statsBody = m.stats.viewport.View()40+ if len(stats.cards) == 0 {41+ statsBody = stats.viewport.View()4242 } else {43- banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)43+ banner := ui.RenderStatsBanner(stats.profile, statsWidth)4444 statsBody = lipgloss.JoinVertical(lipgloss.Left,4545 banner,4646 "",47- m.stats.viewport.View(),47+ stats.viewport.View(),4848 )4949 }5050 statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)51- return m.renderPanel("Stats", statsBody, "↑/↓ scroll • esc back")51+ return renderPanelView(width, height, notice, "Stats", statsBody, "↑/↓ scroll • esc back")5252 }
M app/weekly.go +19 -12
@@ -37,7 +37,7 @@ func (r weeklyRow) tableRow() table.Row {
3737 }3838 }3939 40-func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {40+func (m model) enterWeeklyView() (model, tea.Cmd) {4141 current := weekly.Current(time.Now())4242 m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)4343 m = m.refreshWeeklyBrowser()
@@ -71,23 +71,30 @@ func (m model) refreshWeeklyBrowser() model {
7171 }7272 7373 func (m model) isCurrentWeeklySelection() bool {74+ return isCurrentWeeklySelection(m.weekly.cursor)75+}76+77+func isCurrentWeeklySelection(cursor time.Time) bool {7478 current := weekly.Current(time.Now())75- year, weekNumber := m.selectedWeek()79+ year, weekNumber := resolvedWeekCursor(cursor).ISOWeek()7680 return current.Year == year && current.Week == weekNumber7781 }7882 79-func (m model) selectedWeek() (int, int) {80- cursor := m.weekly.cursor83+func weeklyPanelTitle(cursor time.Time) string {84+ year, weekNumber := resolvedWeekCursor(cursor).ISOWeek()85+ return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)86+}87+88+func resolvedWeekCursor(cursor time.Time) time.Time {8189 if cursor.IsZero() {8290 current := weekly.Current(time.Now())83- cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)91+ return weekly.StartOfWeek(current.Year, current.Week, time.Local)8492 }85- return cursor.ISOWeek()93+ return cursor8694 }8795 88-func (m model) weeklyPanelTitle() string {89- year, weekNumber := m.selectedWeek()90- return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)96+func (m model) selectedWeek() (int, int) {97+ return resolvedWeekCursor(m.weekly.cursor).ISOWeek()9198 }9299 93100 func (m model) moveWeeklyWeek(delta int) model {
@@ -107,7 +114,7 @@ func (m model) moveWeeklyWeek(delta int) model {
107114 return m.refreshWeeklyBrowser()108115 }109116 110-func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {117+func (m model) handleWeeklyEnter() (model, tea.Cmd) {111118 idx := m.weekly.table.Cursor()112119 if idx < 0 || idx >= len(m.weekly.rows) {113120 return m, nil
@@ -116,7 +123,7 @@ func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {
116123 return m.openWeeklyRow(m.weekly.rows[idx])117124 }118125 119-func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {126+func (m model) openWeeklyRow(row weeklyRow) (model, tea.Cmd) {120127 info, ok := weekly.ParseName(row.Name)121128 if !ok {122129 return m, nil
@@ -189,7 +196,7 @@ func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {
189196 }190197 191198 next, cmd := m.openWeeklyRow(m.weekly.rows[0])192- return next.(model), cmd, true199+ return next, cmd, true193200 }194201 195202 func buildCurrentWeeklyRows(year, weekNumber int, games []store.GameRecord) []weeklyRow {
M architecture_test.go +5 -5
@@ -17,7 +17,7 @@ func TestPuzzlePackageStaysPure(t *testing.T) {
1717 "charm.land/bubbles",1818 "charm.land/lipgloss",1919 "github.com/FelineStateMachine/puzzletea/theme",20- "github.com/FelineStateMachine/puzzletea/pdfexport",20+ "github.com/FelineStateMachine/puzzletea/export/pdf",2121 })2222 }2323
@@ -40,7 +40,7 @@ func TestStorePackageDoesNotImportSchedulePackages(t *testing.T) {
4040 4141 func TestGamePackageDoesNotImportPDFExport(t *testing.T) {4242 assertPackageDoesNotImport(t, "game", []string{43- "github.com/FelineStateMachine/puzzletea/pdfexport",43+ "github.com/FelineStateMachine/puzzletea/export/pdf",4444 })4545 }4646
@@ -49,7 +49,7 @@ func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {
4949 }5050 5151 func TestBuiltinPrintDoesNotImportConcreteGames(t *testing.T) {52- assertPackageDoesNotImport(t, "builtinprint", concreteGameImportPaths(t))52+ assertPackageDoesNotImport(t, "export/builtinprint", concreteGameImportPaths(t))5353 }5454 5555 func TestSessionPackageDoesNotUseNameDerivedRunMetadata(t *testing.T) {
@@ -101,7 +101,7 @@ func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {
101101 func concreteGameImportPaths(t testing.TB) []string {102102 t.Helper()103103 104- pattern := filepath.Join(repoRoot(t), "*", "gamemode.go")104+ pattern := filepath.Join(repoRoot(t), "games", "*", "gamemode.go")105105 matches, err := filepath.Glob(pattern)106106 if err != nil {107107 t.Fatalf("glob concrete game packages: %v", err)
@@ -110,7 +110,7 @@ func concreteGameImportPaths(t testing.TB) []string {
110110 importPaths := make([]string, 0, len(matches))111111 for _, match := range matches {112112 dir := filepath.Base(filepath.Dir(match))113- importPaths = append(importPaths, "github.com/FelineStateMachine/puzzletea/"+dir)113+ importPaths = append(importPaths, "github.com/FelineStateMachine/puzzletea/games/"+dir)114114 }115115 slices.Sort(importPaths)116116 return importPaths
M cmd/export.go +1 -1
M cmd/export_pdf.go +3 -3
M cmd/export_pdf_test.go +1 -1
M cmd/export_test.go +1 -1
M cmd/new_export.go +3 -3
M cmd/new_export_test.go +1 -1
M cmd/test.go +1 -1
R builtinprint/register.go → export/builtinprint/register.go +1 -1
R builtinprint/register_test.go → export/builtinprint/register_test.go +1 -1
R packexport/packexport_test.go → export/pack/packexport_test.go +1 -1
R packexport/render.go → export/pack/render.go +1 -1
R packexport/runtime.go → export/pack/runtime.go +1 -1
R packexport/spec.go → export/pack/spec.go +2 -2
R pdfexport/font.go → export/pdf/font.go +0 -0

No content changes.

R pdfexport/font_test.go → export/pdf/font_test.go +0 -0

No content changes.

R pdfexport/fonts.go → export/pdf/fonts.go +0 -0

No content changes.

R pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Bold.ttf +0 -0

No content changes.

R pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt → export/pdf/fonts/AtkinsonHyperlegibleNext-OFL.txt +0 -0

No content changes.

R pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Regular.ttf +0 -0

No content changes.

R pdfexport/fonts/DMSerifDisplay-OFL.txt → export/pdf/fonts/DMSerifDisplay-OFL.txt +0 -0

No content changes.

R pdfexport/fonts/DMSerifDisplay-Regular.ttf → export/pdf/fonts/DMSerifDisplay-Regular.ttf +0 -0

No content changes.

R pdfexport/jsonl.go → export/pdf/jsonl.go +0 -0

No content changes.

R pdfexport/jsonl_test.go → export/pdf/jsonl_test.go +0 -0

No content changes.

R pdfexport/normalize.go → export/pdf/normalize.go +0 -0

No content changes.

R pdfexport/order.go → export/pdf/order.go +0 -0

No content changes.

R pdfexport/order_test.go → export/pdf/order_test.go +0 -0

No content changes.

R pdfexport/parse_fillomino.go → export/pdf/parse_fillomino.go +0 -0

No content changes.

R pdfexport/parse_hashi.go → export/pdf/parse_hashi.go +0 -0

No content changes.

R pdfexport/parse_helpers.go → export/pdf/parse_helpers.go +0 -0

No content changes.

R pdfexport/parse_hitori.go → export/pdf/parse_hitori.go +0 -0

No content changes.

R pdfexport/parse_netwalk.go → export/pdf/parse_netwalk.go +0 -0

No content changes.

R pdfexport/parse_nonogram.go → export/pdf/parse_nonogram.go +0 -0

No content changes.

R pdfexport/parse_nurikabe.go → export/pdf/parse_nurikabe.go +0 -0

No content changes.

R pdfexport/parse_rippleeffect.go → export/pdf/parse_rippleeffect.go +0 -0

No content changes.

R pdfexport/parse_shikaku.go → export/pdf/parse_shikaku.go +0 -0

No content changes.

R pdfexport/parse_sudoku.go → export/pdf/parse_sudoku.go +0 -0

No content changes.

R pdfexport/parse_takuzu.go → export/pdf/parse_takuzu.go +0 -0

No content changes.

R pdfexport/parse_takuzuplus.go → export/pdf/parse_takuzuplus.go +0 -0

No content changes.

R pdfexport/parse_wordsearch.go → export/pdf/parse_wordsearch.go +0 -0

No content changes.

R pdfexport/print_adapter.go → export/pdf/print_adapter.go +0 -0

No content changes.

R pdfexport/printdata_test.go → export/pdf/printdata_test.go +0 -0

No content changes.

R pdfexport/render.go → export/pdf/render.go +0 -0

No content changes.

R pdfexport/render_cover.go → export/pdf/render_cover.go +0 -0

No content changes.

R pdfexport/render_cover_test.go → export/pdf/render_cover_test.go +0 -0

No content changes.

R pdfexport/render_difficulty.go → export/pdf/render_difficulty.go +0 -0

No content changes.

R pdfexport/render_difficulty_test.go → export/pdf/render_difficulty_test.go +0 -0

No content changes.

R pdfexport/render_error_test.go → export/pdf/render_error_test.go +0 -0

No content changes.

R pdfexport/render_instructions.go → export/pdf/render_instructions.go +0 -0

No content changes.

R pdfexport/render_instructions_test.go → export/pdf/render_instructions_test.go +0 -0

No content changes.

R pdfexport/render_kit.go → export/pdf/render_kit.go +0 -0

No content changes.

R pdfexport/render_layout_test.go → export/pdf/render_layout_test.go +0 -0

No content changes.

R pdfexport/render_metadata.go → export/pdf/render_metadata.go +0 -0

No content changes.

R pdfexport/render_plan.go → export/pdf/render_plan.go +0 -0

No content changes.

R pdfexport/render_title.go → export/pdf/render_title.go +0 -0

No content changes.

R pdfexport/render_title_test.go → export/pdf/render_title_test.go +0 -0

No content changes.

R pdfexport/render_tokens.go → export/pdf/render_tokens.go +0 -0

No content changes.

R pdfexport/types.go → export/pdf/types.go +0 -0

No content changes.

M gameentry/gameentry.go +1 -1
R fillomino/README.md → games/fillomino/README.md +0 -0

No content changes.

R fillomino/export.go → games/fillomino/export.go +0 -0

No content changes.

R fillomino/fillomino_test.go → games/fillomino/fillomino_test.go +0 -0

No content changes.

R fillomino/gamemode.go → games/fillomino/gamemode.go +0 -0

No content changes.

R fillomino/generator.go → games/fillomino/generator.go +0 -0

No content changes.

R fillomino/grid.go → games/fillomino/grid.go +0 -0

No content changes.

R fillomino/help.md → games/fillomino/help.md +0 -0

No content changes.

R fillomino/keys.go → games/fillomino/keys.go +0 -0

No content changes.

R fillomino/model.go → games/fillomino/model.go +0 -0

No content changes.

R fillomino/mouse.go → games/fillomino/mouse.go +0 -0

No content changes.

R fillomino/print_adapter.go → games/fillomino/print_adapter.go +1 -1
R fillomino/print_adapter_test.go → games/fillomino/print_adapter_test.go +0 -0

No content changes.

R fillomino/solver.go → games/fillomino/solver.go +0 -0

No content changes.

R fillomino/style.go → games/fillomino/style.go +0 -0

No content changes.

R fillomino/testdata/visual_states.jsonl → games/fillomino/testdata/visual_states.jsonl +0 -0

No content changes.

R hashiwokakero/README.md → games/hashiwokakero/README.md +0 -0

No content changes.

R hashiwokakero/benchmark_test.go → games/hashiwokakero/benchmark_test.go +0 -0

No content changes.

R hashiwokakero/export.go → games/hashiwokakero/export.go +0 -0

No content changes.

R hashiwokakero/gamemode.go → games/hashiwokakero/gamemode.go +0 -0

No content changes.

R hashiwokakero/generation_invariants_test.go → games/hashiwokakero/generation_invariants_test.go +0 -0

No content changes.

R hashiwokakero/generator.go → games/hashiwokakero/generator.go +0 -0

No content changes.

R hashiwokakero/hashiwokakero_test.go → games/hashiwokakero/hashiwokakero_test.go +0 -0

No content changes.

R hashiwokakero/help.md → games/hashiwokakero/help.md +0 -0

No content changes.

R hashiwokakero/island.go → games/hashiwokakero/island.go +0 -0

No content changes.

R hashiwokakero/keys.go → games/hashiwokakero/keys.go +0 -0

No content changes.

R hashiwokakero/model.go → games/hashiwokakero/model.go +0 -0

No content changes.

R hashiwokakero/mouse.go → games/hashiwokakero/mouse.go +0 -0

No content changes.

R hashiwokakero/print_adapter.go → games/hashiwokakero/print_adapter.go +1 -1
R hashiwokakero/style.go → games/hashiwokakero/style.go +0 -0

No content changes.

R hashiwokakero/testdata/visual_states.jsonl → games/hashiwokakero/testdata/visual_states.jsonl +0 -0

No content changes.

R hitori/README.md → games/hitori/README.md +0 -0

No content changes.

R hitori/benchmark_test.go → games/hitori/benchmark_test.go +0 -0

No content changes.

R hitori/export.go → games/hitori/export.go +0 -0

No content changes.

R hitori/gamemode.go → games/hitori/gamemode.go +0 -0

No content changes.

R hitori/generator.go → games/hitori/generator.go +0 -0

No content changes.

R hitori/grid.go → games/hitori/grid.go +0 -0

No content changes.

R hitori/help.md → games/hitori/help.md +0 -0

No content changes.

R hitori/hitori_test.go → games/hitori/hitori_test.go +0 -0

No content changes.

R hitori/keys.go → games/hitori/keys.go +0 -0

No content changes.

R hitori/model.go → games/hitori/model.go +0 -0

No content changes.

R hitori/mouse.go → games/hitori/mouse.go +0 -0

No content changes.

R hitori/print_adapter.go → games/hitori/print_adapter.go +1 -1
R hitori/solver.go → games/hitori/solver.go +0 -0

No content changes.

R hitori/style.go → games/hitori/style.go +0 -0

No content changes.

R hitori/testdata/visual_states.jsonl → games/hitori/testdata/visual_states.jsonl +0 -0

No content changes.

R lightsout/README.md → games/lightsout/README.md +0 -0

No content changes.

R lightsout/benchmark_test.go → games/lightsout/benchmark_test.go +0 -0

No content changes.

R lightsout/export.go → games/lightsout/export.go +0 -0

No content changes.

R lightsout/gamemode.go → games/lightsout/gamemode.go +0 -0

No content changes.

R lightsout/generator.go → games/lightsout/generator.go +0 -0

No content changes.

R lightsout/help.md → games/lightsout/help.md +0 -0

No content changes.

R lightsout/keys.go → games/lightsout/keys.go +0 -0

No content changes.

R lightsout/lightsout_test.go → games/lightsout/lightsout_test.go +0 -0

No content changes.

R lightsout/model.go → games/lightsout/model.go +0 -0

No content changes.

R lightsout/style.go → games/lightsout/style.go +0 -0

No content changes.

R lightsout/testdata/visual_states.jsonl → games/lightsout/testdata/visual_states.jsonl +0 -0

No content changes.

R netwalk/README.md → games/netwalk/README.md +0 -0

No content changes.

R netwalk/export.go → games/netwalk/export.go +0 -0

No content changes.

R netwalk/gamemode.go → games/netwalk/gamemode.go +0 -0

No content changes.

R netwalk/generator.go → games/netwalk/generator.go +0 -0

No content changes.

R netwalk/grid.go → games/netwalk/grid.go +0 -0

No content changes.

R netwalk/help.md → games/netwalk/help.md +0 -0

No content changes.

R netwalk/keys.go → games/netwalk/keys.go +0 -0

No content changes.

R netwalk/model.go → games/netwalk/model.go +0 -0

No content changes.

R netwalk/netwalk_test.go → games/netwalk/netwalk_test.go +0 -0

No content changes.

R netwalk/print_adapter.go → games/netwalk/print_adapter.go +1 -1
R netwalk/style.go → games/netwalk/style.go +0 -0

No content changes.

R netwalk/testdata/visual_states.jsonl → games/netwalk/testdata/visual_states.jsonl +0 -0

No content changes.

R nonogram/README.md → games/nonogram/README.md +0 -0

No content changes.

R nonogram/dynamic_grid_test.go → games/nonogram/dynamic_grid_test.go +0 -0

No content changes.

R nonogram/export.go → games/nonogram/export.go +0 -0

No content changes.

R nonogram/gamemode.go → games/nonogram/gamemode.go +0 -0

No content changes.

R nonogram/generator.go → games/nonogram/generator.go +0 -0

No content changes.

R nonogram/grid.go → games/nonogram/grid.go +0 -0

No content changes.

R nonogram/help.md → games/nonogram/help.md +0 -0

No content changes.

R nonogram/keys.go → games/nonogram/keys.go +0 -0

No content changes.

R nonogram/model.go → games/nonogram/model.go +0 -0

No content changes.

R nonogram/mouse.go → games/nonogram/mouse.go +0 -0

No content changes.

R nonogram/nonogram_test.go → games/nonogram/nonogram_test.go +0 -0

No content changes.

R nonogram/print_adapter.go → games/nonogram/print_adapter.go +1 -1
R nonogram/print_adapter_test.go → games/nonogram/print_adapter_test.go +1 -1
R nonogram/style.go → games/nonogram/style.go +0 -0

No content changes.

R nonogram/testdata/visual_states.jsonl → games/nonogram/testdata/visual_states.jsonl +0 -0

No content changes.

R nonogram/tomography.go → games/nonogram/tomography.go +0 -0

No content changes.

R nurikabe/README.md → games/nurikabe/README.md +0 -0

No content changes.

R nurikabe/export.go → games/nurikabe/export.go +0 -0

No content changes.

R nurikabe/gamemode.go → games/nurikabe/gamemode.go +0 -0

No content changes.

R nurikabe/generator.go → games/nurikabe/generator.go +0 -0

No content changes.

R nurikabe/grid.go → games/nurikabe/grid.go +0 -0

No content changes.

R nurikabe/help.md → games/nurikabe/help.md +0 -0

No content changes.

R nurikabe/keys.go → games/nurikabe/keys.go +0 -0

No content changes.

R nurikabe/model.go → games/nurikabe/model.go +0 -0

No content changes.

R nurikabe/mouse.go → games/nurikabe/mouse.go +0 -0

No content changes.

R nurikabe/nurikabe_test.go → games/nurikabe/nurikabe_test.go +0 -0

No content changes.

R nurikabe/print_adapter.go → games/nurikabe/print_adapter.go +1 -1
R nurikabe/solver.go → games/nurikabe/solver.go +0 -0

No content changes.

R nurikabe/style.go → games/nurikabe/style.go +0 -0

No content changes.

R nurikabe/testdata/visual_states.jsonl → games/nurikabe/testdata/visual_states.jsonl +0 -0

No content changes.

R rippleeffect/README.md → games/rippleeffect/README.md +3 -3
R rippleeffect/export.go → games/rippleeffect/export.go +0 -0

No content changes.

R rippleeffect/gamemode.go → games/rippleeffect/gamemode.go +31 -26
@@ -64,10 +64,11 @@ var Modes = []game.Mode{
6464 3,6565 0.69,6666 generationProfile{67- cageWeights: []int{0, 5, 6, 3},68- frontierSamples: 3,69- shapeBias: shapeBiasCompact,70- minGivensByCage: []int{0, 1, 1, 2},67+ cageWeights: []int{0, 5, 6, 3},68+ frontierSamples: 3,69+ shapeBias: shapeBiasCompact,70+ minGivensByCage: []int{0, 1, 1, 2},71+ maxSingletonCages: -1,7172 },7273 ),7374 NewModeWithProfile(
@@ -77,49 +78,53 @@ var Modes = []game.Mode{
7778 3,7879 0.64,7980 generationProfile{80- cageWeights: []int{0, 3, 5, 4},81- frontierSamples: 3,82- shapeBias: shapeBiasCompact,83- minGivensByCage: []int{0, 1, 1, 2},81+ cageWeights: []int{0, 3, 5, 4},82+ frontierSamples: 3,83+ shapeBias: shapeBiasCompact,84+ minGivensByCage: []int{0, 1, 1, 2},85+ maxSingletonCages: -1,8486 },8587 ),8688 NewModeWithProfile(8789 "Medium 7x7",88- "Mixed cage shapes with a balanced clue spread.",90+ "Mixed cage shapes with sparser anchors and roomier deductions.",8991 7,9092 4,91- 0.59,93+ 0.50,9294 generationProfile{93- cageWeights: []int{0, 2, 4, 5, 3},94- frontierSamples: 2,95- shapeBias: shapeBiasNeutral,96- minGivensByCage: []int{0, 1, 1, 1, 1},95+ cageWeights: []int{0, 0, 3, 5, 4},96+ frontierSamples: 2,97+ shapeBias: shapeBiasNeutral,98+ minGivensByCage: []int{0, 0, 0, 0, 0},99+ maxSingletonCages: 1,97100 },98101 ),99102 NewModeWithProfile(100103 "Hard 8x8",101- "Longer cages and lighter anchors create broader ripple scans.",104+ "Longer cages and sparse anchors reward broader ripple reads.",102105 8,103106 4,104- 0.55,107+ 0.46,105108 generationProfile{106- cageWeights: []int{0, 1, 2, 4, 5},107- frontierSamples: 3,108- shapeBias: shapeBiasWinding,109- minGivensByCage: []int{0, 1, 1, 1, 1},109+ cageWeights: []int{0, 0, 1, 4, 5},110+ frontierSamples: 3,111+ shapeBias: shapeBiasWinding,112+ minGivensByCage: []int{0, 0, 0, 0, 0},113+ maxSingletonCages: 1,110114 },111115 ),112116 NewModeWithProfile(113117 "Expert 9x9",114- "Sparser anchors and winding large cages push global deductions.",118+ "Winding large cages and sparse anchors push global deductions.",115119 9,116120 5,117- 0.51,121+ 0.42,118122 generationProfile{119- cageWeights: []int{0, 1, 1, 3, 4, 5},120- frontierSamples: 4,121- shapeBias: shapeBiasWinding,122- minGivensByCage: []int{0, 1, 1, 1, 1, 1},123+ cageWeights: []int{0, 0, 1, 2, 4, 5},124+ frontierSamples: 4,125+ shapeBias: shapeBiasWinding,126+ minGivensByCage: []int{0, 0, 0, 0, 0, 0},127+ maxSingletonCages: 1,123128 },124129 ),125130 }
R rippleeffect/generator.go → games/rippleeffect/generator.go +142 -11
@@ -22,10 +22,11 @@ const (
2222 )2323 2424 type generationProfile struct {25- cageWeights []int26- frontierSamples int27- shapeBias shapeBias28- minGivensByCage []int25+ cageWeights []int26+ frontierSamples int27+ shapeBias shapeBias28+ minGivensByCage []int29+ maxSingletonCages int2930 }3031 3132 func GeneratePuzzle(width, height, maxCage int, givenRatio float64) (Puzzle, error) {
@@ -38,10 +39,11 @@ func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *r
3839 }3940 4041 func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio float64, profile generationProfile, rng *rand.Rand) (Puzzle, error) {41- const maxAttempts = 2442+ const maxAttempts = 484243 profile = profile.withDefaults(maxCage)4344 for range maxAttempts {4445 cages := generateCages(width, height, maxCage, profile, rng)46+ cages = normalizeSingletonCages(width, height, maxCage, cages, profile, rng)4547 geo, err := buildGeometry(width, height, cages)4648 if err != nil {4749 continue
@@ -71,10 +73,11 @@ func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio floa
7173 7274 func defaultGenerationProfile(maxCage int) generationProfile {7375 return generationProfile{74- cageWeights: defaultCageWeights(maxCage),75- frontierSamples: 1,76- shapeBias: shapeBiasNeutral,77- minGivensByCage: make([]int, maxCage+1),76+ cageWeights: defaultCageWeights(maxCage),77+ frontierSamples: 1,78+ shapeBias: shapeBiasNeutral,79+ minGivensByCage: make([]int, maxCage+1),80+ maxSingletonCages: -1,7881 }7982 }8083
@@ -112,6 +115,134 @@ func growIntSlice(values []int, size int) []int {
112115 return grown113116 }114117 118+func normalizeSingletonCages(width, height, maxCage int, cages []Cage, profile generationProfile, rng *rand.Rand) []Cage {119+ if profile.maxSingletonCages < 0 {120+ return cages121+ }122+123+ normalized := append([]Cage(nil), cages...)124+ for countSingletonCages(normalized) > profile.maxSingletonCages {125+ singletons := singletonCageIndices(normalized)126+ if len(singletons) == 0 {127+ break128+ }129+130+ singletonIdx := singletons[rng.IntN(len(singletons))]131+ targetIdx, ok := chooseSingletonMergeTarget(width, height, maxCage, normalized, singletonIdx, rng)132+ if !ok {133+ break134+ }135+136+ cell := normalized[singletonIdx].Cells[0]137+ normalized[targetIdx].Cells = append(normalized[targetIdx].Cells, cell)138+ normalized[targetIdx].Size = len(normalized[targetIdx].Cells)139+ normalized = append(normalized[:singletonIdx], normalized[singletonIdx+1:]...)140+ }141+142+ return renumberCages(normalized)143+}144+145+func countSingletonCages(cages []Cage) int {146+ count := 0147+ for _, cage := range cages {148+ if len(cage.Cells) == 1 {149+ count++150+ }151+ }152+ return count153+}154+155+func singletonCageIndices(cages []Cage) []int {156+ indices := make([]int, 0)157+ for idx, cage := range cages {158+ if len(cage.Cells) == 1 {159+ indices = append(indices, idx)160+ }161+ }162+ return indices163+}164+165+func chooseSingletonMergeTarget(width, height, maxCage int, cages []Cage, singletonIdx int, rng *rand.Rand) (int, bool) {166+ singleton := cages[singletonIdx]167+ if len(singleton.Cells) != 1 {168+ return 0, false169+ }170+171+ cageGrid := buildCageIndexGrid(width, height, cages)172+ cell := singleton.Cells[0]173+ neighbors := make([]int, 0, 4)174+ seen := make(map[int]struct{}, 4)175+ for _, next := range orthogonalNeighbors(point{x: cell.X, y: cell.Y}, width, height) {176+ cageIdx := cageGrid[next.y][next.x]177+ if cageIdx < 0 || cageIdx == singletonIdx {178+ continue179+ }180+ if _, exists := seen[cageIdx]; exists {181+ continue182+ }183+ seen[cageIdx] = struct{}{}184+ neighbors = append(neighbors, cageIdx)185+ }186+ if len(neighbors) == 0 {187+ return 0, false188+ }189+190+ allowed := make([]int, 0, len(neighbors))191+ for _, idx := range neighbors {192+ if len(cages[idx].Cells)+1 <= maxCage {193+ allowed = append(allowed, idx)194+ }195+ }196+ if len(allowed) > 0 {197+ neighbors = allowed198+ }199+200+ minSize := len(cages[neighbors[0]].Cells)201+ best := make([]int, 0, len(neighbors))202+ for _, idx := range neighbors {203+ size := len(cages[idx].Cells)204+ if size < minSize {205+ minSize = size206+ best = best[:0]207+ }208+ if size == minSize {209+ best = append(best, idx)210+ }211+ }212+213+ return best[rng.IntN(len(best))], true214+}215+216+func buildCageIndexGrid(width, height int, cages []Cage) [][]int {217+ grid := make([][]int, height)218+ for y := range height {219+ grid[y] = make([]int, width)220+ for x := range width {221+ grid[y][x] = -1222+ }223+ }224+225+ for cageIdx, cage := range cages {226+ for _, cell := range cage.Cells {227+ grid[cell.Y][cell.X] = cageIdx228+ }229+ }230+231+ return grid232+}233+234+func renumberCages(cages []Cage) []Cage {235+ renumbered := make([]Cage, len(cages))236+ for idx, cage := range cages {237+ renumbered[idx] = Cage{238+ ID: idx,239+ Size: len(cage.Cells),240+ Cells: append([]Cell(nil), cage.Cells...),241+ }242+ }243+ return renumbered244+}245+115246 func generateCages(width, height, maxCage int, profile generationProfile, rng *rand.Rand) []Cage {116247 assigned := make([][]bool, height)117248 for y := range height {
@@ -394,7 +525,7 @@ func minGivensForCage(profile generationProfile, cageSize int) int {
394525 return 0395526 }396527 if cageSize >= len(profile.minGivensByCage) {397- return max(1, profile.minGivensByCage[len(profile.minGivensByCage)-1])528+ return max(0, min(profile.minGivensByCage[len(profile.minGivensByCage)-1], cageSize))398529 }399- return max(1, min(profile.minGivensByCage[cageSize], cageSize))530+ return max(0, min(profile.minGivensByCage[cageSize], cageSize))400531 }
R rippleeffect/geometry.go → games/rippleeffect/geometry.go +0 -0

No content changes.

R rippleeffect/grid.go → games/rippleeffect/grid.go +0 -0

No content changes.

R rippleeffect/help.md → games/rippleeffect/help.md +0 -0

No content changes.

R rippleeffect/keys.go → games/rippleeffect/keys.go +0 -0

No content changes.

R rippleeffect/model.go → games/rippleeffect/model.go +0 -0

No content changes.

R rippleeffect/print_adapter.go → games/rippleeffect/print_adapter.go +1 -1
R rippleeffect/rippleeffect_test.go → games/rippleeffect/rippleeffect_test.go +89 -0
@@ -187,6 +187,79 @@ func TestGeneratePuzzleSeeded(t *testing.T) {
187187 }188188 }189189 190+func TestMinGivensForCageAllowsZero(t *testing.T) {191+ profile := generationProfile{minGivensByCage: []int{0, 0, 0, 0}}192+193+ if got := minGivensForCage(profile, 1); got != 0 {194+ t.Fatalf("minGivensForCage(..., 1) = %d, want 0", got)195+ }196+ if got := minGivensForCage(profile, 3); got != 0 {197+ t.Fatalf("minGivensForCage(..., 3) = %d, want 0", got)198+ }199+}200+201+func TestNormalizeSingletonCagesCapsSingletons(t *testing.T) {202+ rng := rand.New(rand.NewPCG(77, 99))203+ cages := []Cage{204+ {ID: 0, Size: 1, Cells: []Cell{{0, 0}}},205+ {ID: 1, Size: 1, Cells: []Cell{{1, 0}}},206+ {ID: 2, Size: 2, Cells: []Cell{{2, 0}, {2, 1}}},207+ {ID: 3, Size: 2, Cells: []Cell{{0, 1}, {0, 2}}},208+ {ID: 4, Size: 3, Cells: []Cell{{1, 1}, {1, 2}, {2, 2}}},209+ }210+211+ normalized := normalizeSingletonCages(3, 3, 3, cages, generationProfile{maxSingletonCages: 1}, rng)212+ if got := countSingletonCages(normalized); got > 1 {213+ t.Fatalf("singleton cages = %d, want <= 1", got)214+ }215+216+ geo, err := buildGeometry(3, 3, normalized)217+ if err != nil {218+ t.Fatalf("buildGeometry() after normalization: %v", err)219+ }220+ if got := len(geo.cages); got != len(normalized) {221+ t.Fatalf("geometry cages = %d, want %d", got, len(normalized))222+ }223+}224+225+func TestSparseModesGenerateUniquePuzzlesWithLimitedSingletons(t *testing.T) {226+ modeIndices := []int{2, 3, 4}227+ sampleCount := 6228+ sawZeroGivenCage := false229+230+ for _, idx := range modeIndices {231+ mode := Modes[idx].(Mode)232+233+ for sample := range sampleCount {234+ rng := rand.New(rand.NewPCG(uint64(7000+idx*31+sample), uint64(8000+idx*37+sample)))235+ puzzle, err := mode.generatePuzzleSeeded(rng)236+ if err != nil {237+ t.Fatalf("%s generatePuzzleSeeded failed: %v", mode.Title(), err)238+ }239+240+ geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)241+ if err != nil {242+ t.Fatalf("%s buildGeometry failed: %v", mode.Title(), err)243+ }244+ if got := countSolutions(geo, puzzle.Givens, 2); got != 1 {245+ t.Fatalf("%s solutions = %d, want 1", mode.Title(), got)246+ }247+248+ if got := countSingletonCages(puzzle.Cages); got > 1 {249+ t.Fatalf("%s singleton cages = %d, want <= 1", mode.Title(), got)250+ }251+252+ if countZeroGivenCages(puzzle) > 0 {253+ sawZeroGivenCage = true254+ }255+ }256+ }257+258+ if !sawZeroGivenCage {259+ t.Fatal("expected sampled medium+ puzzles to include at least one zero-given cage")260+ }261+}262+190263 func TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {191264 if testing.Short() {192265 t.Skip("skipping sampled ripple given-value distribution test in short mode")
@@ -688,3 +761,19 @@ func formatValueDistribution(counts []int) string {
688761 }689762 return strings.Join(parts, ", ")690763 }764+765+func countZeroGivenCages(puzzle Puzzle) int {766+ count := 0767+ for _, cage := range puzzle.Cages {768+ givens := 0769+ for _, cell := range cage.Cells {770+ if puzzle.Givens[cell.Y][cell.X] != 0 {771+ givens++772+ }773+ }774+ if givens == 0 {775+ count++776+ }777+ }778+ return count779+}
R rippleeffect/solver.go → games/rippleeffect/solver.go +0 -0

No content changes.

R rippleeffect/style.go → games/rippleeffect/style.go +0 -0

No content changes.

R rippleeffect/testdata/visual_states.jsonl → games/rippleeffect/testdata/visual_states.jsonl +0 -0

No content changes.

R shikaku/README.md → games/shikaku/README.md +0 -0

No content changes.

R shikaku/benchmark_test.go → games/shikaku/benchmark_test.go +0 -0

No content changes.

R shikaku/export.go → games/shikaku/export.go +0 -0

No content changes.

R shikaku/gamemode.go → games/shikaku/gamemode.go +0 -0

No content changes.

R shikaku/generation_invariants_test.go → games/shikaku/generation_invariants_test.go +0 -0

No content changes.

R shikaku/generator.go → games/shikaku/generator.go +0 -0

No content changes.

R shikaku/help.md → games/shikaku/help.md +0 -0

No content changes.

R shikaku/keys.go → games/shikaku/keys.go +0 -0

No content changes.

R shikaku/model.go → games/shikaku/model.go +0 -0

No content changes.

R shikaku/mouse.go → games/shikaku/mouse.go +0 -0

No content changes.

R shikaku/print_adapter.go → games/shikaku/print_adapter.go +1 -1
R shikaku/puzzle.go → games/shikaku/puzzle.go +0 -0

No content changes.

R shikaku/shikaku_test.go → games/shikaku/shikaku_test.go +0 -0

No content changes.

R shikaku/style.go → games/shikaku/style.go +0 -0

No content changes.

R shikaku/testdata/visual_states.jsonl → games/shikaku/testdata/visual_states.jsonl +0 -0

No content changes.

R spellpuzzle/README.md → games/spellpuzzle/README.md +0 -0

No content changes.

R spellpuzzle/board.go → games/spellpuzzle/board.go +0 -0

No content changes.

R spellpuzzle/export.go → games/spellpuzzle/export.go +0 -0

No content changes.

R spellpuzzle/gamemode.go → games/spellpuzzle/gamemode.go +0 -0

No content changes.

R spellpuzzle/generator.go → games/spellpuzzle/generator.go +0 -0

No content changes.

R spellpuzzle/help.md → games/spellpuzzle/help.md +0 -0

No content changes.

R spellpuzzle/keys.go → games/spellpuzzle/keys.go +0 -0

No content changes.

R spellpuzzle/lexicon.go → games/spellpuzzle/lexicon.go +0 -0

No content changes.

R spellpuzzle/model.go → games/spellpuzzle/model.go +0 -0

No content changes.

R spellpuzzle/print_adapter.go → games/spellpuzzle/print_adapter.go +1 -1
R spellpuzzle/print_adapter_test.go → games/spellpuzzle/print_adapter_test.go +1 -1
R spellpuzzle/spellpuzzle_test.go → games/spellpuzzle/spellpuzzle_test.go +0 -0

No content changes.

R spellpuzzle/style.go → games/spellpuzzle/style.go +0 -0

No content changes.

R spellpuzzle/twl06.txt → games/spellpuzzle/twl06.txt +0 -0

No content changes.

R sudoku/README.md → games/sudoku/README.md +0 -0

No content changes.

R sudoku/benchmark_test.go → games/sudoku/benchmark_test.go +0 -0

No content changes.

R sudoku/cell.go → games/sudoku/cell.go +0 -0

No content changes.

R sudoku/dynamic_board_test.go → games/sudoku/dynamic_board_test.go +0 -0

No content changes.

R sudoku/export.go → games/sudoku/export.go +0 -0

No content changes.

R sudoku/gamemode.go → games/sudoku/gamemode.go +0 -0

No content changes.

R sudoku/generator.go → games/sudoku/generator.go +0 -0

No content changes.

R sudoku/help.md → games/sudoku/help.md +0 -0

No content changes.

R sudoku/keys.go → games/sudoku/keys.go +0 -0

No content changes.

R sudoku/model.go → games/sudoku/model.go +0 -0

No content changes.

R sudoku/mouse.go → games/sudoku/mouse.go +0 -0

No content changes.

R sudoku/print_adapter.go → games/sudoku/print_adapter.go +1 -1
R sudoku/style.go → games/sudoku/style.go +0 -0

No content changes.

R sudoku/sudoku_test.go → games/sudoku/sudoku_test.go +0 -0

No content changes.

R sudoku/testdata/visual_states.jsonl → games/sudoku/testdata/visual_states.jsonl +0 -0

No content changes.

R sudoku/uniqueness_test.go → games/sudoku/uniqueness_test.go +0 -0

No content changes.

R sudokurgb/README.md → games/sudokurgb/README.md +0 -0

No content changes.

R sudokurgb/analysis.go → games/sudokurgb/analysis.go +0 -0

No content changes.

R sudokurgb/cell.go → games/sudokurgb/cell.go +0 -0

No content changes.

R sudokurgb/dynamic_board_test.go → games/sudokurgb/dynamic_board_test.go +0 -0

No content changes.

R sudokurgb/export.go → games/sudokurgb/export.go +0 -0

No content changes.

R sudokurgb/gamemode.go → games/sudokurgb/gamemode.go +0 -0

No content changes.

R sudokurgb/generator.go → games/sudokurgb/generator.go +0 -0

No content changes.

R sudokurgb/help.md → games/sudokurgb/help.md +0 -0

No content changes.

R sudokurgb/keys.go → games/sudokurgb/keys.go +0 -0

No content changes.

R sudokurgb/model.go → games/sudokurgb/model.go +0 -0

No content changes.

R sudokurgb/mouse.go → games/sudokurgb/mouse.go +0 -0

No content changes.

R sudokurgb/print_adapter.go → games/sudokurgb/print_adapter.go +1 -1
R sudokurgb/style.go → games/sudokurgb/style.go +0 -0

No content changes.

R sudokurgb/sudokurgb_test.go → games/sudokurgb/sudokurgb_test.go +0 -0

No content changes.

R sudokurgb/testdata/visual_states.jsonl → games/sudokurgb/testdata/visual_states.jsonl +0 -0

No content changes.

R takuzu/README.md → games/takuzu/README.md +0 -0

No content changes.

R takuzu/benchmark_test.go → games/takuzu/benchmark_test.go +0 -0

No content changes.

R takuzu/export.go → games/takuzu/export.go +0 -0

No content changes.

R takuzu/gamemode.go → games/takuzu/gamemode.go +0 -0

No content changes.

R takuzu/generator.go → games/takuzu/generator.go +0 -0

No content changes.

R takuzu/grid.go → games/takuzu/grid.go +0 -0

No content changes.

R takuzu/help.md → games/takuzu/help.md +0 -0

No content changes.

R takuzu/keys.go → games/takuzu/keys.go +0 -0

No content changes.

R takuzu/model.go → games/takuzu/model.go +0 -0

No content changes.

R takuzu/mouse.go → games/takuzu/mouse.go +0 -0

No content changes.

R takuzu/print_adapter.go → games/takuzu/print_adapter.go +1 -1
R takuzu/print_adapter_test.go → games/takuzu/print_adapter_test.go +1 -1
R takuzu/style.go → games/takuzu/style.go +0 -0

No content changes.

R takuzu/takuzu_test.go → games/takuzu/takuzu_test.go +0 -0

No content changes.

R takuzu/testdata/visual_states.jsonl → games/takuzu/testdata/visual_states.jsonl +0 -0

No content changes.

R takuzuplus/README.md → games/takuzuplus/README.md +0 -0

No content changes.

R takuzuplus/export.go → games/takuzuplus/export.go +0 -0

No content changes.

R takuzuplus/gamemode.go → games/takuzuplus/gamemode.go +0 -0

No content changes.

R takuzuplus/generator.go → games/takuzuplus/generator.go +1 -1
R takuzuplus/grid.go → games/takuzuplus/grid.go +1 -1
R takuzuplus/help.md → games/takuzuplus/help.md +0 -0

No content changes.

R takuzuplus/keys.go → games/takuzuplus/keys.go +0 -0

No content changes.

R takuzuplus/logic.go → games/takuzuplus/logic.go +1 -1
R takuzuplus/model.go → games/takuzuplus/model.go +1 -1
R takuzuplus/mouse.go → games/takuzuplus/mouse.go +0 -0

No content changes.

R takuzuplus/print_adapter.go → games/takuzuplus/print_adapter.go +2 -2
R takuzuplus/profile.go → games/takuzuplus/profile.go +0 -0

No content changes.

R takuzuplus/style.go → games/takuzuplus/style.go +0 -0

No content changes.

R takuzuplus/takuzuplus_test.go → games/takuzuplus/takuzuplus_test.go +0 -0

No content changes.

R takuzuplus/testdata/visual_states.jsonl → games/takuzuplus/testdata/visual_states.jsonl +0 -0

No content changes.

R wordsearch/README.md → games/wordsearch/README.md +0 -0

No content changes.

R wordsearch/benchmark_test.go → games/wordsearch/benchmark_test.go +0 -0

No content changes.

R wordsearch/export.go → games/wordsearch/export.go +0 -0

No content changes.

R wordsearch/gamemode.go → games/wordsearch/gamemode.go +0 -0

No content changes.

R wordsearch/generator.go → games/wordsearch/generator.go +0 -0

No content changes.

R wordsearch/grid.go → games/wordsearch/grid.go +0 -0

No content changes.

R wordsearch/help.md → games/wordsearch/help.md +0 -0

No content changes.

R wordsearch/keys.go → games/wordsearch/keys.go +0 -0

No content changes.

R wordsearch/model.go → games/wordsearch/model.go +0 -0

No content changes.

R wordsearch/mouse.go → games/wordsearch/mouse.go +0 -0

No content changes.

R wordsearch/print_adapter.go → games/wordsearch/print_adapter.go +1 -1
R wordsearch/style.go → games/wordsearch/style.go +0 -0

No content changes.

R wordsearch/word.go → games/wordsearch/word.go +0 -0

No content changes.

R wordsearch/words_dict.go → games/wordsearch/words_dict.go +0 -0

No content changes.

R wordsearch/wordsearch_test.go → games/wordsearch/wordsearch_test.go +0 -0

No content changes.

M internal/testjsonl/records.go +1 -1
M registry/registry.go +15 -15
@@ -6,24 +6,24 @@ import (
66 "fmt"77 88 "github.com/FelineStateMachine/puzzletea/catalog"9- "github.com/FelineStateMachine/puzzletea/fillomino"109 "github.com/FelineStateMachine/puzzletea/game"1110 "github.com/FelineStateMachine/puzzletea/gameentry"12- "github.com/FelineStateMachine/puzzletea/hashiwokakero"13- "github.com/FelineStateMachine/puzzletea/hitori"14- "github.com/FelineStateMachine/puzzletea/lightsout"15- "github.com/FelineStateMachine/puzzletea/netwalk"16- "github.com/FelineStateMachine/puzzletea/nonogram"17- "github.com/FelineStateMachine/puzzletea/nurikabe"11+ "github.com/FelineStateMachine/puzzletea/games/fillomino"12+ "github.com/FelineStateMachine/puzzletea/games/hashiwokakero"13+ "github.com/FelineStateMachine/puzzletea/games/hitori"14+ "github.com/FelineStateMachine/puzzletea/games/lightsout"15+ "github.com/FelineStateMachine/puzzletea/games/netwalk"16+ "github.com/FelineStateMachine/puzzletea/games/nonogram"17+ "github.com/FelineStateMachine/puzzletea/games/nurikabe"18+ "github.com/FelineStateMachine/puzzletea/games/rippleeffect"19+ "github.com/FelineStateMachine/puzzletea/games/shikaku"20+ "github.com/FelineStateMachine/puzzletea/games/spellpuzzle"21+ "github.com/FelineStateMachine/puzzletea/games/sudoku"22+ "github.com/FelineStateMachine/puzzletea/games/sudokurgb"23+ "github.com/FelineStateMachine/puzzletea/games/takuzu"24+ "github.com/FelineStateMachine/puzzletea/games/takuzuplus"25+ "github.com/FelineStateMachine/puzzletea/games/wordsearch"1826 "github.com/FelineStateMachine/puzzletea/puzzle"19- "github.com/FelineStateMachine/puzzletea/rippleeffect"20- "github.com/FelineStateMachine/puzzletea/shikaku"21- "github.com/FelineStateMachine/puzzletea/spellpuzzle"22- "github.com/FelineStateMachine/puzzletea/sudoku"23- "github.com/FelineStateMachine/puzzletea/sudokurgb"24- "github.com/FelineStateMachine/puzzletea/takuzu"25- "github.com/FelineStateMachine/puzzletea/takuzuplus"26- "github.com/FelineStateMachine/puzzletea/wordsearch"2727 )2828 2929 type (
M registry/registry_test.go +2 -2
M resolve/resolve_test.go +1 -1
M session/session_test.go +1 -1