clone

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

Elo difficulty generation (#68)

FelineStateMachine committed 0cd4b9f
* Add Elo difficulty generation

* clean up

* Introduce variant Elo schema v2

* add filter to remove out and tmp dir from git. scripting shortcuts

* Fix create pool selection and Elo candidate handling

* address legacy support

* Refresh v2 release docs
commit
0cd4b9f10e38776349e0a67c80e089eeff8b0b7e
parent
ded640c8792e99517e56e4456167741daa373265
author
FelineStateMachine <dami.etoile@gmail.com>
125 files changed, +8913 -349
M.gitignore4+
MREADME.md90+++-
Aapp/create_state.go338++++++++++++++++
Aapp/create_state_test.go203+++++++++
Mapp/export_test.go2+-
Aapp/handle_create.go114+++++
Aapp/handle_create_test.go171++++++++
Mapp/mode_display.go24+-
Mapp/mode_display_test.go22+-
Mapp/model.go6+-
Mapp/screen_core.go40+-
Aapp/screen_create.go648++++++++++++++++++++++++++++++
Aapp/screen_create_test.go297++++++++++++++
Mapp/screen_menu.go4+-
Mapp/screen_navigation.go4+-
Mapp/screens_test.go8+-
Mapp/seed_input.go24+-
Mapp/session_controller.go26+-
Mapp/spawn.go20+
Mapp/view.go10+-
Mapp/view_test.go17+
Mcatalog/catalog.go28+
Mcmd/config_test.go4+
Mcmd/new.go107++++-
Mcmd/new_export.go94+++-
Mcmd/new_export_test.go75+++-
Acmd/new_spawn_test.go165++++++++
Mcmd/test.go2+-
Mconfig/config.go7+
Mconfig/config_test.go26+
Mdaily/daily_test.go8+-
Adifficulty/candidates.go43++
Adifficulty/difficulty.go50++
Adifficulty/difficulty_test.go55+++
Mexport/pack/packexport_test.go21+-
Mexport/pack/render.go68+++-
Aexport/pack/render_test.go58+++
Mexport/pack/runtime.go33+-
Mexport/pack/spec.go128++++-
Mexport/pdf/jsonl.go85+++-
Aexport/pdf/jsonl_difficulty_test.go92++++
Mexport/pdf/types.go3+
Mgame/gamer.go17+-
Mgameentry/gameentry.go235++++++++++-
Mgameentry/gameentry_test.go86++++
Mgames/fillomino/README.md5+
Agames/fillomino/elo.go154+++++++
Agames/fillomino/elo_test.go70+++
Mgames/fillomino/gamemode.go17+-
Mgames/hashiwokakero/README.md5+
Agames/hashiwokakero/elo.go155+++++++
Agames/hashiwokakero/elo_test.go133++++++
Mgames/hashiwokakero/gamemode.go20+-
Mgames/hitori/README.md5+
Agames/hitori/elo.go194+++++++++
Agames/hitori/elo_test.go110+++++
Mgames/hitori/gamemode.go20+-
Mgames/hitori/generator.go58++-
Mgames/lightsout/README.md5+
Agames/lightsout/elo.go182++++++++
Agames/lightsout/elo_test.go121++++++
Mgames/lightsout/gamemode.go17+-
Mgames/netwalk/README.md5+
Agames/netwalk/elo.go176++++++++
Mgames/netwalk/gamemode.go17+-
Mgames/netwalk/netwalk_test.go98+++++
Mgames/nonogram/README.md5+
Agames/nonogram/elo.go182++++++++
Agames/nonogram/elo_test.go136++++++
Mgames/nonogram/gamemode.go20+-
Mgames/nonogram/generator.go27+-
Mgames/nurikabe/README.md5+
Agames/nurikabe/elo.go173++++++++
Agames/nurikabe/elo_test.go89++++
Mgames/nurikabe/gamemode.go20+-
Mgames/nurikabe/generator.go9+-
Mgames/rippleeffect/README.md5+
Agames/rippleeffect/elo.go218++++++++++
Mgames/rippleeffect/gamemode.go20+-
Mgames/rippleeffect/rippleeffect_test.go115+++++
Mgames/rippleeffect/solver.go23+-
Mgames/shikaku/README.md5+
Agames/shikaku/elo.go285+++++++++++++
Agames/shikaku/elo_test.go111+++++
Mgames/shikaku/gamemode.go20+-
Mgames/spellpuzzle/README.md5+
Agames/spellpuzzle/elo.go187+++++++++
Mgames/spellpuzzle/gamemode.go17+-
Mgames/spellpuzzle/spellpuzzle_test.go75+++
Mgames/sudoku/README.md5+
Agames/sudoku/elo.go169++++++++
Agames/sudoku/elo_test.go71+++
Mgames/sudoku/gamemode.go17+-
Mgames/sudokurgb/README.md5+
Agames/sudokurgb/elo.go167++++++++
Agames/sudokurgb/elo_test.go71+++
Mgames/sudokurgb/gamemode.go17+-
Mgames/takuzu/README.md5+
Agames/takuzu/elo.go205+++++++++
Agames/takuzu/elo_test.go125++++++
Mgames/takuzu/gamemode.go20+-
Mgames/takuzuplus/README.md5+
Agames/takuzuplus/elo.go245+++++++++++
Agames/takuzuplus/elo_test.go118+++++
Mgames/takuzuplus/gamemode.go20+-
Mgames/wordsearch/README.md5+
Agames/wordsearch/elo.go230+++++++++++
Agames/wordsearch/elo_test.go107+++++
Mgames/wordsearch/gamemode.go17+-
Mpuzzle/puzzle.go105+++++-
Mregistry/registry.go23+-
Mregistry/registry_test.go25+
Mresolve/resolve.go103++++-
Mresolve/resolve_test.go55++-
Msession/session.go51++
Msession/session_test.go14+
Mstats/stats.go53++-
Mstats/stats_test.go41++
Mstore/game_record.go41+-
Mstore/migrate.go108+++++-
Mstore/stats.go9+-
Mstore/stats_test.go39++
Mstore/store.go56++-
Mstore/store_test.go26+
Mweekly/weekly_test.go8+-
M .gitignore +4 -0
M README.md +70 -20
@@ -2,7 +2,7 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Fifteen puzzle games, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 theme options, and an explicit built-in registry plus metadata catalog for adding new games.5+Fifteen puzzle games, Elo-backed difficulty presets, daily and weekly deterministic challenges, XP progression, 365 theme options, and an explicit built-in registry plus metadata catalog for adding new games.66 77 ![PuzzleTea menu](vhs/menu.gif)88
@@ -11,10 +11,11 @@ Fifteen puzzle games, multiple difficulty modes, daily and weekly deterministic
1111 - **15 puzzle games** -- Fillomino, Netwalk, Nonogram, Nurikabe, Ripple Effect, Shikaku, Sudoku, Sudoku RGB, Spell Puzzle, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+1212 - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.1313 - **Weekly gauntlet** -- Each ISO calendar week has a shared 99-puzzle ladder. The current week unlocks sequentially from `#01` to `#99`; past weeks can be reviewed from completed saves only.14-- **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP, and weekly puzzles add slot-based bonus XP.14+- **XP and leveling** -- Per-category levels based on victories. Elo-rated puzzles yield XP from their recorded difficulty. Daily puzzles grant 2x XP, and weekly puzzles add slot-based bonus XP.1515 - **Stats dashboard** -- Profile level, daily streak status, weekly completion progress, victory counts, and XP progress bars per category.1616 - **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included.1717 - **Mouse support** -- Drag interactions in Nonogram, Nurikabe, Shikaku, Spell Puzzle, and Word Search; click-to-focus in Fillomino, Hashiwokakero, Hitori, Sudoku, Sudoku RGB, Takuzu, and Takuzu+; click-to-rotate in Netwalk; click-to-toggle in Lights Out.18+- **Elo difficulty** -- Puzzle generation can target a `0..3000` Elo difficulty. Named modes remain friendly presets, and generated records store target/actual Elo when available.1819 - **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines.1920 - **Save/load persistence** -- Games auto-save to SQLite. Resume any in-progress game by name.2021
@@ -88,11 +89,18 @@ puzzletea
8889 8990 The Play menu includes:9091 91-- `Create` for a new puzzle by category and mode92+- `Create` for a new puzzle from a checked pool of games/variants/board sizes, with a target Elo difficulty9293 - `Continue` for saved games9394 - `Daily` for the shared deterministic daily puzzle9495 - `Weekly` for the current or historical weekly gauntlet95-- `Seeded` for a custom deterministic seed96+97+Create replaces the old separate Seeded flow. Check one or more leaf nodes,98+enter an Elo value from `0` to `3000`, and generate one puzzle. If exactly one99+leaf is checked, the seed field is enabled and a nonblank seed resumes the100+matching deterministic save when one already exists. If multiple leaves are101+checked, PuzzleTea picks one checked leaf uniformly at random and disables the102+seed field because the selected game is intentionally random. The Create pool103+and last Elo are saved in `~/.puzzletea/config.json`.96104 97105 Weekly gauntlets use the ISO week-year format shown in the menu, for example98106 `Week 10-2026 # 7`. The `#` value is the currently active weekly challenge for
@@ -115,6 +123,18 @@ puzzletea new hashi "Easy 7x7"
115123 Mode names are matched case-insensitively after normalizing spaces, hyphens,116124 and underscores. Multi-word mode titles usually need quotes.117125 126+Target an explicit Elo difficulty for games and modes that support Elo-backed127+generation:128+129+```bash130+puzzletea new sudoku --difficulty 1200131+puzzletea new nonogram classic --difficulty 1800 --with-seed myseed132+```133+134+`--difficulty` accepts integers from `0` through `3000`. When it is present,135+PuzzleTea uses the mode's Elo spawner and records `target_difficulty_elo`,136+`actual_difficulty_elo`, and `difficulty_confidence` in the save metadata.137+118138 Resume and manage saved games:119139 120140 ```bash
@@ -133,6 +153,10 @@ puzzletea new --set-seed myseed
133153 puzzletea new nonogram epic --with-seed myseed134154 ```135155 156+For interactive seeded play, use `Create`, check exactly one leaf, and enter the157+seed there. With multiple checked leaves, the Create seed field is intentionally158+disabled.159+136160 Export printable puzzle sets to JSONL:137161 138162 ```bash
@@ -144,6 +168,9 @@ puzzletea new nonogram mini -e 6 -o nonogram-mini-set.jsonl
144168 145169 # Mixed modes within a category (deterministic with --with-seed)146170 puzzletea new sudoku --export 10 -o sudoku-mixed.jsonl --with-seed zine-issue-01171+172+# Elo-targeted export173+puzzletea new sudoku --export 10 --difficulty 1500 -o sudoku-1500.jsonl147174 ```148175 149176 Render one or more JSONL packs into a print PDF:
@@ -168,11 +195,15 @@ puzzletea export-pdf nonogram-mini-set.jsonl -o issue-01-duplex.pdf --shuffle-se
168195 169196 Font license note (Atkinson Hyperlegible Next):170197 171-- Follow the SIL OFL 1.1 requirements in `pdfexport/fonts/OFL.txt`.198+- Follow the SIL OFL 1.1 requirements in `export/pdf/fonts/AtkinsonHyperlegibleNext-OFL.txt`.172199 - Do not sell the font files by themselves.173200 - If redistributing fonts with software, include the copyright notice and OFL text.174201 - Modified font versions must keep OFL terms, and modified names must respect Reserved Font Name rules.175202 203+JSONL exports include Elo metadata when generation produced a difficulty204+report. PDF ordering and difficulty stars prefer actual Elo, then target Elo,205+then legacy mode-order fallback.206+176207 `Lights Out` is currently excluded from export because it does not translate cleanly to paper workflows.177208 178209 Override the color theme:
@@ -225,96 +256,101 @@ Grow numbered regions so each connected region reaches its exact size.
225256 226257 ![Fillomino](vhs/fillomino.gif)227258 228-[Game details and controls](fillomino/README.md)259+[Game details and controls](games/fillomino/README.md)229260 230261 ### Ripple Effect231262 Place digits in cages while keeping matching values outside each other’s ripple distance.232263 233264 ![Ripple Effect](vhs/rippleeffect.gif)234265 235-[Game details and controls](rippleeffect/README.md)266+[Game details and controls](games/rippleeffect/README.md)236267 237268 ### Nonogram238269 Fill cells to match row and column hints.239270 240271 ![Nonogram](vhs/nonogram.gif)241272 242-[Game details and controls](nonogram/README.md)273+[Game details and controls](games/nonogram/README.md)243274 244275 ### Nurikabe245276 Build islands from clues while keeping one connected sea.246277 247278 ![Nurikabe](vhs/nurikabe.gif)248279 249-[Game details and controls](nurikabe/README.md)280+[Game details and controls](games/nurikabe/README.md)250281 251282 ### Shikaku252283 Divide the grid into rectangles, where each rectangle contains exactly the number of cells shown in its clue.253284 254285 ![Shikaku](vhs/shikaku.gif)255286 256-[Game details and controls](shikaku/README.md)287+[Game details and controls](games/shikaku/README.md)257288 258289 ### Sudoku259290 Classic 9x9 number placement puzzle.260291 261292 ![Sudoku](vhs/sudoku.gif)262293 263-[Game details and controls](sudoku/README.md)294+[Game details and controls](games/sudoku/README.md)264295 265296 ### Sudoku RGB266297 Fill the grid with three symbols so every row, column, and box contains `{1,1,1,2,2,2,3,3,3}`.267298 268299 ![Sudoku RGB](vhs/sudokurgb.gif)269300 270-[Game details and controls](sudokurgb/README.md)301+[Game details and controls](games/sudokurgb/README.md)271302 272303 ### Word Search273304 Find hidden words in a letter grid.274305 275306 ![Word Search](vhs/wordsearch.gif)276307 277-[Game details and controls](wordsearch/README.md)308+[Game details and controls](games/wordsearch/README.md)278309 279310 ### Spell Puzzle280311 Connect letters from a fixed bank to reveal a crossword and score bonus words.281312 282-[Game details and controls](spellpuzzle/README.md)313+[Game details and controls](games/spellpuzzle/README.md)283314 284315 ### Hashiwokakero285316 Connect islands with bridges.286317 287318 ![Hashiwokakero](vhs/hashiwokakero.gif)288319 289-[Game details and controls](hashiwokakero/README.md)320+[Game details and controls](games/hashiwokakero/README.md)290321 291322 ### Hitori292323 Shade cells to eliminate duplicate numbers.293324 294325 ![Hitori](vhs/hitori.gif)295326 296-[Game details and controls](hitori/README.md)327+[Game details and controls](games/hitori/README.md)297328 298329 ### Lights Out299330 Toggle lights to turn all off.300331 301332 ![Lights Out](vhs/lightsout.gif)302333 303-[Game details and controls](lightsout/README.md)334+[Game details and controls](games/lightsout/README.md)304335 305336 ### Netwalk306337 Rotate network tiles until every computer reaches the server in one loop-free tree.307338 308339 ![Netwalk](vhs/netwalk.gif)309340 310-[Game details and controls](netwalk/README.md)341+[Game details and controls](games/netwalk/README.md)311342 312343 ### Takuzu313344 Fill the grid with two symbols following three simple rules.314345 315346 ![Takuzu](vhs/takuzu.gif)316347 317-[Game details and controls](takuzu/README.md)348+[Game details and controls](games/takuzu/README.md)349+350+### Takuzu+351+Fill the grid with two symbols while obeying the normal Takuzu rules plus fixed relation clues.352+353+[Game details and controls](games/takuzuplus/README.md)318354 319355 ## Building and Testing320356
@@ -358,6 +394,7 @@ Create a directory (e.g., `mypuzzle/`) with these files:
358394 | `keys.go` | Game-specific `KeyMap` struct |359395 | `style.go` | lipgloss styling and rendering helpers |360396 | `generator.go` | Puzzle generation logic (if applicable) |397+| `elo.go` | Elo-targeted generation and `difficulty.Report` scoring |361398 | `grid.go` | Grid type and serialization (for grid-based games) |362399 | `help.md` | Embedded rules/help content wired into the runtime entry |363400 | `print_adapter.go` | Optional printable export adapter for JSONL/PDF support |
@@ -365,7 +402,8 @@ Create a directory (e.g., `mypuzzle/`) with these files:
365402 | `README.md` | Game docs: rules, controls table, modes table, quick start examples |366403 367404 Use `gameentry.BuildModeDefs(Modes)` and `gameentry.NewEntry(...)` so the368-package exposes both metadata and its validated runtime wiring.405+package exposes both metadata and its validated runtime wiring. If a mode has a406+preset Elo, its runtime mode must implement `game.EloSpawner`.369407 370408 ### 2. Wire it into the built-in registry371409
@@ -379,6 +417,7 @@ The game package's `Definition` owns:
379417 - description380418 - aliases381419 - menu modes420+- preset Elo values for modes382421 - daily-eligible modes383422 - help content384423 - save/import function
@@ -405,6 +444,17 @@ See any existing game package (e.g., `nonogram/`) for the full pattern, and `AGE
405444 406445 [MIT](LICENSE)407446 447+## Research References448+449+Elo difficulty scoring and game-specific heuristics were informed by public450+puzzle implementations and solver documentation, including Simon Tatham's451+[Portable Puzzle Collection](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/)452+and [source repository](https://git.tartarus.org/?p=simon/puzzles.git),453+[Copris puzzle solvers](https://cspsat.gitlab.io/copris-puzzles/),454+[QQWing Sudoku](https://qqwing.com/), Jaap Scherphuis'455+[Lights Out notes](https://www.jaapsch.net/puzzles/lights.htm), and public456+[Nikoli puzzle rules](https://www.nikoli.co.jp/en/puzzles/).457+408458 ## Built With409459 410460 - [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework
A app/create_state.go +338 -0
@@ -0,0 +1,338 @@
1+package app2+3+import (4+ "fmt"5+ "sort"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/puzzletea/config"10+ "github.com/FelineStateMachine/puzzletea/difficulty"11+ "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/puzzle"13+ "github.com/FelineStateMachine/puzzletea/registry"14+15+ "charm.land/bubbles/v2/textinput"16+)17+18+const defaultCreateElo = 120019+20+type createState struct {21+ tree []createTreeNode22+ checked map[string]bool23+ expanded map[string]bool24+ cursor int25+ eloInput textinput.Model26+ seedInput textinput.Model27+ focus createFocus28+}29+30+type createFocus int31+32+const (33+ createFocusTree createFocus = iota34+ createFocusElo35+ createFocusSeed36+ createFocusGenerate37+)38+39+type createTreeNode struct {40+ id string41+ title string42+ leaf *createLeaf43+ children []createTreeNode44+}45+46+type createLeaf struct {47+ id string48+ title string49+ gameType string50+ variant registry.VariantEntry51+}52+53+type createVisibleNode struct {54+ node createTreeNode55+ depth int56+}57+58+func newCreateState(cfg config.CreateConfig, width int) createState {59+ elo := cfg.Elo60+ if err := difficulty.ValidateElo(difficulty.Elo(elo)); err != nil {61+ elo = defaultCreateElo62+ }63+64+ eloInput := textinput.New()65+ eloInput.Prompt = ""66+ eloInput.CharLimit = 467+ eloInput.SetWidth(8)68+ eloInput.SetValue(strconv.Itoa(elo))69+70+ seedInput := textinput.New()71+ seedInput.Prompt = ""72+ seedInput.Placeholder = "optional seed"73+ seedInput.CharLimit = 6474+ seedInput.SetWidth(min(width, 48))75+76+ tree := buildCreateTree(registry.Entries())77+ validLeafIDs := createLeafIDSet(tree)78+ state := createState{79+ tree: tree,80+ checked: make(map[string]bool),81+ expanded: make(map[string]bool),82+ eloInput: eloInput,83+ seedInput: seedInput,84+ focus: createFocusTree,85+ }86+ for _, id := range cfg.SelectedLeafIDs {87+ if !validLeafIDs[id] {88+ continue89+ }90+ state.checked[id] = true91+ state.expandAncestors(id)92+ }93+ if len(state.checked) == 0 {94+ state.expandTopLevel()95+ }96+ return state97+}98+99+func buildCreateTree(entries []registry.Entry) []createTreeNode {100+ builders := make(map[string]*createNodeBuilder)101+ order := make([]string, 0, len(entries))102+ for _, entry := range entries {103+ topTitle, branchTitle := createGrouping(entry.Definition.Name)104+ topID := "group:" + puzzle.NormalizeName(topTitle)105+ builder, ok := builders[topID]106+ if !ok {107+ builder = &createNodeBuilder{id: topID, title: topTitle}108+ builders[topID] = builder109+ order = append(order, topID)110+ }111+ builder.addBranch(entry, branchTitle)112+ }113+114+ nodes := make([]createTreeNode, 0, len(order))115+ for _, id := range order {116+ nodes = append(nodes, builders[id].node())117+ }118+ return nodes119+}120+121+type createNodeBuilder struct {122+ id string123+ title string124+ branches []createTreeNode125+}126+127+func (b *createNodeBuilder) addBranch(entry registry.Entry, branchTitle string) {128+ leaves := createLeavesForEntry(entry, branchTitle)129+ if branchTitle == "" {130+ b.branches = append(b.branches, leaves...)131+ return132+ }133+134+ branchID := b.id + "/branch:" + puzzle.NormalizeName(branchTitle)135+ if len(leaves) == 1 {136+ b.branches = append(b.branches, leaves[0])137+ return138+ }139+ b.branches = append(b.branches, createTreeNode{140+ id: branchID,141+ title: branchTitle,142+ children: leaves,143+ })144+}145+146+func (b *createNodeBuilder) node() createTreeNode {147+ if len(b.branches) == 1 && b.branches[0].leaf != nil {148+ return b.branches[0]149+ }150+ return createTreeNode{id: b.id, title: b.title, children: b.branches}151+}152+153+func createGrouping(gameType string) (topTitle, branchTitle string) {154+ switch gameType {155+ case "Takuzu", "Takuzu+":156+ return "Takuzu", gameType157+ case "Sudoku", "Sudoku RGB":158+ return "Sudoku", gameType159+ default:160+ return gameType, ""161+ }162+}163+164+func createLeavesForEntry(entry registry.Entry, branchTitle string) []createTreeNode {165+ nodes := make([]createTreeNode, 0, len(entry.Variants))166+ for _, variant := range entry.Variants {167+ if variant.Elo == nil {168+ continue169+ }170+ title := variant.Definition.Title171+ id := createLeafID(entry.Definition.ID, branchTitle, title)172+ nodes = append(nodes, createTreeNode{173+ id: id,174+ title: title,175+ leaf: &createLeaf{176+ id: id,177+ title: title,178+ gameType: entry.Definition.Name,179+ variant: variant,180+ },181+ })182+ }183+ return nodes184+}185+186+func createLeafID(gameID puzzle.GameID, branchTitle, title string) string {187+ parts := []string{string(gameID)}188+ if branchTitle != "" {189+ parts = append(parts, puzzle.NormalizeName(branchTitle))190+ }191+ parts = append(parts, puzzle.NormalizeName(title))192+ return strings.Join(parts, "/")193+}194+195+func (s createState) selectedLeaves() []createLeaf {196+ leaves := make([]createLeaf, 0, len(s.checked))197+ var walk func([]createTreeNode)198+ walk = func(nodes []createTreeNode) {199+ for _, node := range nodes {200+ if node.leaf != nil && s.checked[node.leaf.id] {201+ leaves = append(leaves, *node.leaf)202+ }203+ walk(node.children)204+ }205+ }206+ walk(s.tree)207+ return leaves208+}209+210+func (s createState) selectedLeafIDs() []string {211+ leaves := s.selectedLeaves()212+ ids := make([]string, 0, len(leaves))213+ for _, leaf := range leaves {214+ ids = append(ids, leaf.id)215+ }216+ sort.Strings(ids)217+ return ids218+}219+220+func (s createState) selectedCount() int {221+ return len(s.selectedLeaves())222+}223+224+func (s createState) descendantLeafCounts(node createTreeNode) (selected, total int) {225+ var walk func(createTreeNode)226+ walk = func(node createTreeNode) {227+ if node.leaf != nil {228+ total++229+ if s.checked[node.leaf.id] {230+ selected++231+ }232+ return233+ }234+ for _, child := range node.children {235+ walk(child)236+ }237+ }238+ walk(node)239+ return selected, total240+}241+242+func (s *createState) toggleDescendantLeaves(node createTreeNode) {243+ selected, total := s.descendantLeafCounts(node)244+ if total == 0 {245+ return246+ }247+ checked := selected != total248+ var walk func(createTreeNode)249+ walk = func(node createTreeNode) {250+ if node.leaf != nil {251+ s.checked[node.leaf.id] = checked252+ return253+ }254+ for _, child := range node.children {255+ walk(child)256+ }257+ }258+ walk(node)259+}260+261+func (s createState) seedEnabled() bool {262+ return s.selectedCount() == 1263+}264+265+func (s createState) elo() (difficulty.Elo, error) {266+ value, err := strconv.Atoi(strings.TrimSpace(s.eloInput.Value()))267+ if err != nil {268+ return 0, fmt.Errorf("Elo must be a number")269+ }270+ elo := difficulty.Elo(value)271+ if err := difficulty.ValidateElo(elo); err != nil {272+ return 0, err273+ }274+ return elo, nil275+}276+277+func (s createState) visibleNodes() []createVisibleNode {278+ visible := make([]createVisibleNode, 0)279+ var walk func([]createTreeNode, int)280+ walk = func(nodes []createTreeNode, depth int) {281+ for _, node := range nodes {282+ visible = append(visible, createVisibleNode{node: node, depth: depth})283+ if node.leaf == nil && s.expanded[node.id] {284+ walk(node.children, depth+1)285+ }286+ }287+ }288+ walk(s.tree, 0)289+ return visible290+}291+292+func (s *createState) expandTopLevel() {293+ for _, node := range s.tree {294+ s.expanded[node.id] = false295+ }296+}297+298+func (s *createState) expandAncestors(leafID string) {299+ var walk func([]createTreeNode, []string) bool300+ walk = func(nodes []createTreeNode, ancestors []string) bool {301+ for _, node := range nodes {302+ if node.id == leafID {303+ for _, id := range ancestors {304+ s.expanded[id] = true305+ }306+ return true307+ }308+ if walk(node.children, append(ancestors, node.id)) {309+ return true310+ }311+ }312+ return false313+ }314+ walk(s.tree, nil)315+}316+317+func (s createState) resolveLeafVariant(leaf createLeaf, elo difficulty.Elo) (game.EloSpawner, string, error) {318+ if leaf.variant.Elo == nil {319+ return nil, "", fmt.Errorf("%s %s does not support Elo generation", leaf.gameType, leaf.title)320+ }321+ return leaf.variant.Elo, leaf.variant.Definition.Title, nil322+}323+324+func createLeafIDSet(nodes []createTreeNode) map[string]bool {325+ ids := make(map[string]bool)326+ var walk func([]createTreeNode)327+ walk = func(nodes []createTreeNode) {328+ for _, node := range nodes {329+ if node.leaf != nil {330+ ids[node.leaf.id] = true331+ continue332+ }333+ walk(node.children)334+ }335+ }336+ walk(nodes)337+ return ids338+}
A app/create_state_test.go +203 -0
@@ -0,0 +1,203 @@
1+package app2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/config"7+ "github.com/FelineStateMachine/puzzletea/difficulty"8+)9+10+func TestCreateStateStartsCollapsedAndRestoresCheckedLeaves(t *testing.T) {11+ const leafID = "nonogram/nonogram"12+ state := newCreateState(config.CreateConfig{13+ SelectedLeafIDs: []string{leafID},14+ Elo: 1500,15+ }, 80)16+17+ if !state.checked[leafID] {18+ t.Fatalf("expected %q to be checked", leafID)19+ }20+ if got := state.eloInput.Value(); got != "1500" {21+ t.Fatalf("elo input = %q, want 1500", got)22+ }23+ for _, item := range state.visibleNodes() {24+ if item.node.leaf != nil && item.node.leaf.id == leafID {25+ return26+ }27+ }28+ t.Fatalf("visible nodes = %#v, want restored top-level leaf %q", state.visibleNodes(), leafID)29+}30+31+func TestCreateStateIgnoresUnknownRestoredLeafIDs(t *testing.T) {32+ const validLeafID = "nonogram/nonogram"33+ state := newCreateState(config.CreateConfig{34+ SelectedLeafIDs: []string{35+ "nonogram/5x5",36+ validLeafID,37+ "stale/game/mode",38+ },39+ }, 80)40+41+ if got := state.selectedCount(); got != 1 {42+ t.Fatalf("selected count = %d, want 1", got)43+ }44+ if got := state.selectedLeafIDs(); len(got) != 1 || got[0] != validLeafID {45+ t.Fatalf("selected leaf IDs = %#v, want [%q]", got, validLeafID)46+ }47+ if len(state.checked) != 1 || !state.checked[validLeafID] {48+ t.Fatalf("checked map = %#v, want only %q", state.checked, validLeafID)49+ }50+}51+52+func TestCreateStateDefaultStartsCollapsed(t *testing.T) {53+ state := newCreateState(config.CreateConfig{}, 80)54+55+ if len(state.visibleNodes()) != len(state.tree) {56+ t.Fatalf("visible node count = %d, want %d", len(state.visibleNodes()), len(state.tree))57+ }58+}59+60+func TestCreateTreeGroupsTakuzuAndSudokuVariants(t *testing.T) {61+ state := newCreateState(config.CreateConfig{}, 80)62+63+ takuzu := requireCreateNode(t, state.tree, "Takuzu")64+ if !hasCreateChild(takuzu, "Takuzu") || !hasCreateChild(takuzu, "Takuzu+") {65+ t.Fatalf("Takuzu group children = %#v, want Takuzu and Takuzu+", childTitles(takuzu))66+ }67+ for _, child := range takuzu.children {68+ if child.leaf == nil {69+ t.Fatalf("Takuzu child %q = %#v, want leaf", child.title, child)70+ }71+ }72+73+ sudoku := requireCreateNode(t, state.tree, "Sudoku")74+ if !hasCreateChild(sudoku, "Sudoku") || !hasCreateChild(sudoku, "Sudoku RGB") {75+ t.Fatalf("Sudoku group children = %#v, want Sudoku and Sudoku RGB", childTitles(sudoku))76+ }77+ for _, child := range sudoku.children {78+ if child.leaf == nil {79+ t.Fatalf("Sudoku child %q = %#v, want leaf", child.title, child)80+ }81+ }82+}83+84+func TestCreateTreePromotesSingleVariantGameToTopLevelLeaf(t *testing.T) {85+ state := newCreateState(config.CreateConfig{}, 80)86+ nonogram := requireCreateNode(t, state.tree, "Nonogram")87+88+ if nonogram.leaf == nil {89+ t.Fatalf("Nonogram node = %#v, want top-level leaf", nonogram)90+ }91+ if len(nonogram.children) != 0 {92+ t.Fatalf("Nonogram children = %#v, want none", childTitles(nonogram))93+ }94+}95+96+func TestCreateResolveLeafVariantUsesSelectedVariant(t *testing.T) {97+ state := newCreateState(config.CreateConfig{}, 80)98+ nonogram := requireCreateNode(t, state.tree, "Nonogram")99+ leaf := nonogram.leaf100+ if leaf == nil {101+ t.Fatal("Nonogram node is not a leaf")102+ }103+104+ _, modeTitle, err := state.resolveLeafVariant(*leaf, difficulty.Elo(700))105+ if err != nil {106+ t.Fatal(err)107+ }108+ if modeTitle != "Nonogram" {109+ t.Fatalf("modeTitle = %q, want Nonogram", modeTitle)110+ }111+}112+113+func TestCreateStateDescendantLeafCountsIncludeCollapsedDescendants(t *testing.T) {114+ state := newCreateState(config.CreateConfig{}, 80)115+ nonogram := requireCreateNode(t, state.tree, "Nonogram")116+ state.checked[nonogram.leaf.id] = true117+118+ selected, total := state.descendantLeafCounts(nonogram)119+ if selected != 1 || total != 1 {120+ t.Fatalf("descendant counts = %d/%d, want 1/1", selected, total)121+ }122+}123+124+func TestCreateStateToggleDescendantLeavesSelectsAllWhenAnyUnchecked(t *testing.T) {125+ state := newCreateState(config.CreateConfig{}, 80)126+ sudoku := requireCreateNode(t, state.tree, "Sudoku")127+ state.checked[firstDescendantLeafID(t, sudoku.children[0])] = true128+129+ state.toggleDescendantLeaves(sudoku)130+131+ selected, total := state.descendantLeafCounts(sudoku)132+ if selected != total || total == 0 {133+ t.Fatalf("descendant counts after select-all = %d/%d, want all selected", selected, total)134+ }135+}136+137+func TestCreateStateToggleDescendantLeavesClearsFullySelectedParent(t *testing.T) {138+ state := newCreateState(config.CreateConfig{}, 80)139+ nonogram := requireCreateNode(t, state.tree, "Nonogram")140+ state.toggleDescendantLeaves(nonogram)141+142+ state.toggleDescendantLeaves(nonogram)143+144+ selected, total := state.descendantLeafCounts(nonogram)145+ if selected != 0 || total == 0 {146+ t.Fatalf("descendant counts after clear = %d/%d, want 0 selected", selected, total)147+ }148+}149+150+func TestCreateStateToggleDescendantLeavesWorksForGroupedLeaf(t *testing.T) {151+ state := newCreateState(config.CreateConfig{}, 80)152+ takuzu := requireCreateNode(t, state.tree, "Takuzu")153+ takuzuPlus := requireCreateNode(t, takuzu.children, "Takuzu+")154+155+ state.toggleDescendantLeaves(takuzuPlus)156+157+ selected, total := state.descendantLeafCounts(takuzuPlus)158+ if selected != total || total == 0 {159+ t.Fatalf("Takuzu+ descendant counts after toggle = %d/%d, want all selected", selected, total)160+ }161+}162+163+func requireCreateNode(t *testing.T, nodes []createTreeNode, title string) createTreeNode {164+ t.Helper()165+ for _, node := range nodes {166+ if node.title == title {167+ return node168+ }169+ }170+ t.Fatalf("missing create node %q in %#v", title, childTitles(createTreeNode{children: nodes}))171+ return createTreeNode{}172+}173+174+func hasCreateChild(node createTreeNode, title string) bool {175+ for _, child := range node.children {176+ if child.title == title {177+ return true178+ }179+ }180+ return false181+}182+183+func childTitles(node createTreeNode) []string {184+ titles := make([]string, 0, len(node.children))185+ for _, child := range node.children {186+ titles = append(titles, child.title)187+ }188+ return titles189+}190+191+func firstDescendantLeafID(t *testing.T, node createTreeNode) string {192+ t.Helper()193+ if node.leaf != nil {194+ return node.leaf.id195+ }196+ for _, child := range node.children {197+ if id := firstDescendantLeafID(t, child); id != "" {198+ return id199+ }200+ }201+ t.Fatalf("node %q has no descendant leaf", node.title)202+ return ""203+}
M app/export_test.go +1 -1
A app/handle_create.go +114 -0
@@ -0,0 +1,114 @@
1+package app2+3+import (4+ "math/rand/v2"5+6+ "github.com/FelineStateMachine/puzzletea/config"7+ sessionflow "github.com/FelineStateMachine/puzzletea/session"8+ "github.com/FelineStateMachine/puzzletea/store"9+10+ tea "charm.land/bubbletea/v2"11+)12+13+func (m model) handleCreateGenerate() (model, tea.Cmd) {14+ leaves := m.create.selectedLeaves()15+ if len(leaves) == 0 {16+ return m.setErrorf("Select at least one puzzle."), nil17+ }18+19+ elo, err := m.create.elo()20+ if err != nil {21+ return m.setErrorf("Invalid Elo: %v", err), nil22+ }23+24+ leaf := leaves[m.createChoiceIndex(len(leaves))]25+ spawner, modeTitle, err := m.create.resolveLeafVariant(leaf, elo)26+ if err != nil {27+ return m.setErrorf("Could not prepare puzzle: %v", err), nil28+ }29+30+ if err := m.persistCreatePreset(); err != nil {31+ return m.setErrorf("Could not save create settings: %v", err), nil32+ }33+34+ seed := ""35+ name := sessionflow.GenerateUniqueName(m.store)36+ run := store.NormalRunMetadata()37+ if len(leaves) == 1 {38+ seed = normalizedCreateSeed(m.create.seedInput.Value())39+ }40+ if seed != "" {41+ name = sessionflow.SeededNameForCreateLeaf(seed, leaf.gameType, leaf.id, elo)42+ run = store.SeededRunMetadata(seed)43+ rec, err := m.store.GetDailyGame(name)44+ if err != nil {45+ return m.setErrorf("Could not check saved seeded puzzle: %v", err), nil46+ }47+ if rec != nil {48+ var resumed bool49+ m, resumed = m.importAndActivateRecord(*rec)50+ if resumed {51+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {52+ m = m.setErrorf("%v", err)53+ }54+ }55+ return m, nil56+ }57+ } else {58+ seed = name59+ }60+61+ cmd := newSessionController(&m).startEloSpawn(spawner, seed, elo, spawnRequest{62+ source: spawnSourceNormal,63+ name: name,64+ gameType: leaf.gameType,65+ modeTitle: modeTitle,66+ run: run,67+ returnState: createView,68+ exitState: mainMenuView,69+ })70+ return m, cmd71+}72+73+func (m model) createChoiceIndex(n int) int {74+ if n <= 1 {75+ return 076+ }77+ if m.createRandIndex != nil {78+ idx := m.createRandIndex(n)79+ if idx < 0 || idx >= n {80+ return 081+ }82+ return idx83+ }84+ return rand.IntN(n)85+}86+87+func (m model) persistCreatePresetIfValid() model {88+ if len(m.create.selectedLeaves()) == 0 {89+ return m90+ }91+ if _, err := m.create.elo(); err != nil {92+ return m93+ }94+ if err := m.persistCreatePreset(); err != nil {95+ return m.setErrorf("Could not save create settings: %v", err)96+ }97+ return m98+}99+100+func (m model) persistCreatePreset() error {101+ if m.cfg == nil {102+ m.cfg = config.Default()103+ }104+ elo, err := m.create.elo()105+ if err != nil {106+ return err107+ }108+ m.cfg.Create.SelectedLeafIDs = m.create.selectedLeafIDs()109+ m.cfg.Create.Elo = int(elo)110+ if m.configPath == "" {111+ return nil112+ }113+ return m.cfg.Save(m.configPath)114+}
A app/handle_create_test.go +171 -0
@@ -0,0 +1,171 @@
1+package app2+3+import (4+ "strconv"5+ "testing"6+7+ "charm.land/bubbles/v2/textinput"8+ "github.com/FelineStateMachine/puzzletea/config"9+ "github.com/FelineStateMachine/puzzletea/difficulty"10+ "github.com/FelineStateMachine/puzzletea/game"11+ "github.com/FelineStateMachine/puzzletea/puzzle"12+ "github.com/FelineStateMachine/puzzletea/registry"13+ sessionflow "github.com/FelineStateMachine/puzzletea/session"14+ "github.com/FelineStateMachine/puzzletea/store"15+)16+17+type createTestEloSpawner struct{}18+19+func (createTestEloSpawner) SpawnElo(string, difficulty.Elo) (game.Gamer, difficulty.Report, error) {20+ return &escapeTrackingGame{}, difficulty.Report{21+ TargetElo: 1200,22+ ActualElo: 1200,23+ Confidence: difficulty.ConfidenceHigh,24+ }, nil25+}26+27+func TestCreateGenerateBlocksWithoutSelection(t *testing.T) {28+ m := model{create: createTestState(nil, 1200), store: openAppTestStore(t), cfg: config.Default()}29+30+ got, _ := m.handleCreateGenerate()31+ if got.notice.message == "" {32+ t.Fatal("expected notice for empty selection")33+ }34+}35+36+func TestCreateGenerateUsesSingleSelectedLeafAndSeed(t *testing.T) {37+ s := openAppTestStore(t)38+ state := createTestState([]createLeaf{createTestLeaf("a", "Alpha", "Tiny")}, 1300)39+ state.seedInput.SetValue("daily create seed")40+ m := model{create: state, store: s, cfg: config.Default()}41+42+ got, cmd := m.handleCreateGenerate()43+ if cmd == nil {44+ t.Fatal("expected generation command")45+ }46+ if got.session.spawn == nil {47+ t.Fatal("missing spawn metadata")48+ }49+ if got.session.spawn.gameType != "Alpha" || got.session.spawn.modeTitle != "Tiny" {50+ t.Fatalf("spawn = %#v, want Alpha/Tiny", got.session.spawn)51+ }52+ if got.session.spawn.run.Kind != store.RunKindSeeded {53+ t.Fatalf("run kind = %q, want seeded", got.session.spawn.run.Kind)54+ }55+}56+57+func TestCreateGenerateMultipleSelectionIgnoresSeedAndUsesInjectedRandomChoice(t *testing.T) {58+ s := openAppTestStore(t)59+ state := createTestState([]createLeaf{60+ createTestLeaf("a", "Alpha", "Tiny"),61+ createTestLeaf("b", "Beta", "Large"),62+ }, 1300)63+ state.seedInput.SetValue("ignored")64+ m := model{65+ create: state,66+ store: s,67+ cfg: config.Default(),68+ createRandIndex: func(n int) int {69+ if n != 2 {70+ t.Fatalf("random choice bound = %d, want 2", n)71+ }72+ return 173+ },74+ }75+76+ got, cmd := m.handleCreateGenerate()77+ if cmd == nil {78+ t.Fatal("expected generation command")79+ }80+ if got.session.spawn.gameType != "Beta" {81+ t.Fatalf("spawn gameType = %q, want Beta", got.session.spawn.gameType)82+ }83+ if got.session.spawn.run.Kind != store.RunKindNormal {84+ t.Fatalf("run kind = %q, want normal", got.session.spawn.run.Kind)85+ }86+ if got.session.spawn.run.SeedText != "" {87+ t.Fatalf("seed text = %q, want blank", got.session.spawn.run.SeedText)88+ }89+}90+91+func TestCreateGenerateSeededSingleLeafResumesExistingRecord(t *testing.T) {92+ s := openAppTestStore(t)93+ leaf := createTestLeaf("lights", "Lights Out", "Easy")94+ state := createTestState([]createLeaf{leaf}, 1000)95+ state.seedInput.SetValue("resume create")96+ name := sessionflow.SeededNameForCreateLeaf("resume create", leaf.gameType, leaf.id, 1000)97+ rec := &store.GameRecord{98+ Name: name,99+ GameType: "Lights Out",100+ Mode: "Easy",101+ InitialState: `{"grid":[[true]],"mode_title":"Easy"}`,102+ SaveState: `{"grid":[[true]],"mode_title":"Easy"}`,103+ Status: store.StatusAbandoned,104+ RunKind: store.RunKindSeeded,105+ SeedText: "resume create",106+ }107+ if err := s.CreateGame(rec); err != nil {108+ t.Fatal(err)109+ }110+ m := model{create: state, store: s, cfg: config.Default()}111+112+ got, cmd := m.handleCreateGenerate()113+ if cmd != nil {114+ t.Fatal("expected resume without generation command")115+ }116+ if got.state != gameView {117+ t.Fatalf("state = %d, want gameView", got.state)118+ }119+ saved, err := s.GetDailyGame(name)120+ if err != nil {121+ t.Fatal(err)122+ }123+ if saved.Status != store.StatusInProgress {124+ t.Fatalf("status = %q, want in_progress", saved.Status)125+ }126+}127+128+func createTestState(leaves []createLeaf, elo int) createState {129+ eloInput := textinput.New()130+ eloInput.SetValue("1200")131+ if elo > 0 {132+ eloInput.SetValue(strconv.Itoa(elo))133+ }134+ seedInput := textinput.New()135+136+ children := make([]createTreeNode, 0, len(leaves))137+ checked := make(map[string]bool, len(leaves))138+ for _, leaf := range leaves {139+ leaf := leaf140+ children = append(children, createTreeNode{id: leaf.id, title: leaf.title, leaf: &leaf})141+ checked[leaf.id] = true142+ }143+144+ return createState{145+ tree: []createTreeNode{{146+ id: "group:test",147+ title: "Test",148+ children: children,149+ }},150+ checked: checked,151+ expanded: map[string]bool{"group:test": true},152+ eloInput: eloInput,153+ seedInput: seedInput,154+ }155+}156+157+func createTestLeaf(id, gameType, modeTitle string) createLeaf {158+ spawner := createTestEloSpawner{}159+ return createLeaf{160+ id: id,161+ title: modeTitle,162+ gameType: gameType,163+ variant: registry.VariantEntry{164+ Definition: puzzle.VariantDef{165+ Title: modeTitle,166+ DefaultElo: 1200,167+ },168+ Elo: spawner,169+ },170+ }171+}
M app/mode_display.go +12 -12
@@ -9,22 +9,22 @@ import (
99 )1010 1111 type modeDisplayItem struct {12- mode registry.ModeEntry12+ mode registry.VariantEntry1313 title string1414 description string1515 filterValue string1616 }1717 18-func (i modeDisplayItem) Title() string { return i.title }19-func (i modeDisplayItem) Description() string { return i.description }20-func (i modeDisplayItem) FilterValue() string { return i.filterValue }21-func (i modeDisplayItem) Original() registry.ModeEntry { return i.mode }18+func (i modeDisplayItem) Title() string { return i.title }19+func (i modeDisplayItem) Description() string { return i.description }20+func (i modeDisplayItem) FilterValue() string { return i.filterValue }21+func (i modeDisplayItem) Original() registry.VariantEntry { return i.mode }2222 2323 func buildModeDisplayItems(entry registry.Entry) []list.Item {2424 titles := modeDisplayTitles(entry)25- items := make([]list.Item, 0, len(entry.Modes))25+ items := make([]list.Item, 0, len(entry.Variants))2626 27- for idx, mode := range entry.Modes {27+ for idx, mode := range entry.Variants {2828 displayTitle := titles[idx]2929 filterValue := displayTitle + " " + mode.Definition.Description3030 if displayTitle != mode.Definition.Title {
@@ -43,15 +43,15 @@ func buildModeDisplayItems(entry registry.Entry) []list.Item {
4343 }4444 4545 func modeDisplayTitles(entry registry.Entry) []string {46- counts := make(map[string]int, len(entry.Modes))47- titles := make([]string, 0, len(entry.Modes))46+ counts := make(map[string]int, len(entry.Variants))47+ titles := make([]string, 0, len(entry.Variants))4848 49- for _, mode := range entry.Modes {49+ for _, mode := range entry.Variants {5050 base, _ := trimGridSizeSuffix(mode.Definition.Title)5151 counts[puzzle.NormalizeName(base)]++5252 }5353 54- for _, mode := range entry.Modes {54+ for _, mode := range entry.Variants {5555 title := mode.Definition.Title5656 base, hadSize := trimGridSizeSuffix(title)5757 if hadSize && counts[puzzle.NormalizeName(base)] == 1 {
@@ -64,7 +64,7 @@ func modeDisplayTitles(entry registry.Entry) []string {
6464 }6565 6666 func unwrapModeDisplayItem(item list.Item) any {67- displayItem, ok := item.(interface{ Original() registry.ModeEntry })67+ displayItem, ok := item.(interface{ Original() registry.VariantEntry })6868 if !ok {6969 return item7070 }
M app/mode_display_test.go +11 -11
@@ -21,9 +21,9 @@ func TestModeDisplayTitlesStripUniqueGridSizes(t *testing.T) {
2121 2222 entry := registry.Entry{2323 Definition: puzzle.Definition{Name: "Example"},24- Modes: []registry.ModeEntry{25- {Definition: puzzle.ModeDef{Title: "Mini 5x5", Description: "Mini 5x5 description"}},26- {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},24+ Variants: []registry.VariantEntry{25+ {Definition: puzzle.VariantDef{Title: "Mini 5x5", Description: "Mini 5x5 description"}},26+ {Definition: puzzle.VariantDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},2727 },2828 }2929
@@ -45,10 +45,10 @@ func TestModeDisplayTitlesKeepDuplicateBaseNames(t *testing.T) {
4545 4646 entry := registry.Entry{4747 Definition: puzzle.Definition{Name: "Example"},48- Modes: []registry.ModeEntry{49- {Definition: puzzle.ModeDef{Title: "Easy 5x5", Description: "Easy 5x5 description"}},50- {Definition: puzzle.ModeDef{Title: "Easy 10x10", Description: "Easy 10x10 description"}},51- {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},48+ Variants: []registry.VariantEntry{49+ {Definition: puzzle.VariantDef{Title: "Easy 5x5", Description: "Easy 5x5 description"}},50+ {Definition: puzzle.VariantDef{Title: "Easy 10x10", Description: "Easy 10x10 description"}},51+ {Definition: puzzle.VariantDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},5252 },5353 }5454
@@ -71,8 +71,8 @@ func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {
7171 original := newTestMode("Hard 10x10")7272 entry := registry.Entry{7373 Definition: puzzle.Definition{Name: "Example"},74- Modes: []registry.ModeEntry{{75- Definition: puzzle.ModeDef{Title: original.Title(), Description: original.Description()},74+ Variants: []registry.VariantEntry{{75+ Definition: puzzle.VariantDef{Title: original.Title(), Description: original.Description()},7676 }},7777 }7878
@@ -89,9 +89,9 @@ func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {
8989 t.Fatalf("display item Title() = %q, want %q", got, "Hard")9090 }9191 92- mode, ok := unwrapModeDisplayItem(items[0]).(registry.ModeEntry)92+ mode, ok := unwrapModeDisplayItem(items[0]).(registry.VariantEntry)9393 if !ok {94- t.Fatal("unwrapModeDisplayItem() did not return a registry.ModeEntry")94+ t.Fatal("unwrapModeDisplayItem() did not return a registry.VariantEntry")9595 }9696 if got := mode.Definition.Title; got != "Hard 10x10" {9797 t.Fatalf("unwrapped mode Title() = %q, want %q", got, "Hard 10x10")
M app/model.go +4 -2
M app/screen_core.go +24 -16
@@ -3,6 +3,7 @@ package app
33 import (44 "time"55 6+ "github.com/FelineStateMachine/puzzletea/config"67 "github.com/FelineStateMachine/puzzletea/registry"78 sessionflow "github.com/FelineStateMachine/puzzletea/session"89 "github.com/FelineStateMachine/puzzletea/store"
@@ -46,13 +47,17 @@ func (a quitAction) applyToModel(m model) (model, tea.Cmd) {
4647 return m, tea.Quit4748 }4849 49-type openGameSelectAction struct{}50+type openCreateAction struct{}5051 51-func (a openGameSelectAction) applyToModel(m model) (model, tea.Cmd) {52- m.state = gameSelectView53- m = m.updateCategoryDetailViewport()52+func (a openCreateAction) applyToModel(m model) (model, tea.Cmd) {53+ createCfg := config.CreateConfig{}54+ if m.cfg != nil {55+ createCfg = m.cfg.Create56+ }57+ m.create = newCreateState(createCfg, m.width)58+ m.state = createView5459 m = m.clearNotice()55- return m.initScreen(gameSelectView), nil60+ return m.initScreen(createView), nil5661 }5762 5863 type openContinueAction struct{}
@@ -82,12 +87,6 @@ func (a openWeeklyAction) applyToModel(m model) (model, tea.Cmd) {
8287 return m.enterWeeklyView()8388 }8489 85-type openSeedInputAction struct{}86-87-func (a openSeedInputAction) applyToModel(m model) (model, tea.Cmd) {88- return m.enterSeedInputView()89-}90-9190 type backAction struct {9291 target viewState9392 }
@@ -110,7 +109,7 @@ type gameSelectEnterAction struct {
110109 111110 func (a gameSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {112111 m.nav.selectedCategory = a.entry113- m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(a.entry), a.entry.Definition.Name+" - Select Mode")112+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(a.entry), a.entry.Definition.Name+" - Select Variant")114113 m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))115114 m.state = modeSelectView116115 m = m.initScreen(modeSelectView)
@@ -119,21 +118,27 @@ func (a gameSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {
119118 120119 type modeSelectEnterAction struct {121120 entry registry.Entry122- mode registry.ModeEntry121+ mode registry.VariantEntry123122 }124123 125124 func (a modeSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {126125 m.nav.selectedCategory = a.entry127126 m.nav.selectedModeTitle = a.mode.Definition.Title128- cmd := newSessionController(&m).startSpawn(a.mode.Spawner, spawnRequest{127+ name := sessionflow.GenerateUniqueName(m.store)128+ request := spawnRequest{129129 source: spawnSourceNormal,130- name: sessionflow.GenerateUniqueName(m.store),130+ name: name,131131 gameType: a.entry.Definition.Name,132132 modeTitle: a.mode.Definition.Title,133133 run: store.NormalRunMetadata(),134134 returnState: modeSelectView,135135 exitState: mainMenuView,136- })136+ }137+ if a.mode.Elo != nil {138+ cmd := newSessionController(&m).startEloSpawn(a.mode.Elo, name, a.mode.Definition.DefaultElo, request)139+ return m, cmd140+ }141+ cmd := newSessionController(&m).startSpawn(a.mode.Seeded, request)137142 return m, cmd138143 }139144
@@ -247,6 +252,9 @@ var screenRegistry = map[viewState]screenFactory{
247252 seedInputView: func(m model) screenModel {248253 return seedInputScreen{width: m.width, height: m.height, seed: m.seed}249254 },255+ createView: func(m model) screenModel {256+ return createScreen{width: m.width, height: m.height, create: m.create}257+ },250258 gameSelectView: func(m model) screenModel {251259 return gameSelectScreen{width: m.width, height: m.height, list: m.nav.gameSelectList, detail: m.nav.categoryDetail}252260 },
A app/screen_create.go +648 -0
@@ -0,0 +1,648 @@
1+package app2+3+import (4+ "strconv"5+ "strings"6+7+ sessionflow "github.com/FelineStateMachine/puzzletea/session"8+ "github.com/FelineStateMachine/puzzletea/theme"9+ "github.com/FelineStateMachine/puzzletea/ui"10+11+ "charm.land/bubbles/v2/key"12+ tea "charm.land/bubbletea/v2"13+ "charm.land/lipgloss/v2"14+)15+16+type createScreen struct {17+ width int18+ height int19+ create createState20+}21+22+const createFooterHint = "↑/↓ navigate • enter expand/select/generate • space select • tab field • click select/generate • esc back"23+24+type createRect struct {25+ X int26+ Y int27+ W int28+ H int29+}30+31+func (r createRect) contains(x, y int) bool {32+ return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H33+}34+35+type createSettingsRects struct {36+ Elo createRect37+ Seed createRect38+ TotalHeight int39+}40+41+type createContentMetrics struct {42+ Width int43+ Height int44+ Settings createSettingsRects45+ Tree createRect46+ TreeStart int47+ TreeEnd int48+ Pool createRect49+ Summary createRect50+ SummaryButton createRect51+}52+53+func (s createScreen) Resize(width, height int) screenModel {54+ s.width = width55+ s.height = height56+ contentWidth, _ := createContentBounds(width, height)57+ _, sideWidth := createLayoutWidths(contentWidth)58+ s.create.seedInput.SetWidth(max(10, sideWidth-8))59+ return s60+}61+62+func (s createScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {63+ if clickMsg, ok := msg.(tea.MouseClickMsg); ok {64+ contentWidth, contentHeight := createContentBounds(s.width, s.height)65+ if next, cmd, action, handled := s.handleMouseClick(clickMsg, contentWidth, contentHeight); handled {66+ return next, cmd, action67+ }68+ }69+70+ keyMsg, ok := msg.(tea.KeyPressMsg)71+ if ok {72+ switch {73+ case key.Matches(keyMsg, rootKeys.Enter):74+ return s.handleEnter()75+ case key.Matches(keyMsg, rootKeys.Escape):76+ return s, nil, createBackAction{create: s.create}77+ case keyMsg.String() == "tab":78+ s.create.focus = s.nextFocus(1)79+ return s.syncFocus()80+ case keyMsg.String() == "shift+tab":81+ s.create.focus = s.nextFocus(-1)82+ return s.syncFocus()83+ case keyMsg.String() == "up" || keyMsg.String() == "k":84+ if s.create.focus == createFocusTree {85+ s.moveCursor(-1)86+ return s, nil, nil87+ }88+ case keyMsg.String() == "down" || keyMsg.String() == "j":89+ if s.create.focus == createFocusTree {90+ s.moveCursor(1)91+ return s, nil, nil92+ }93+ case keyMsg.String() == " " || keyMsg.String() == "space":94+ if s.create.focus == createFocusTree {95+ s.toggleCurrentTreeNode()96+ return s, nil, nil97+ }98+ }99+ }100+101+ return s.updateFocusedInput(msg)102+}103+104+func (s createScreen) handleMouseClick(msg tea.MouseClickMsg, contentWidth, contentHeight int) (screenModel, tea.Cmd, screenAction, bool) {105+ mouse := msg.Mouse()106+ content := createBody(s.create, s.width, s.height)107+ _, _, contentX, contentY := createPanelContentOrigin(s.width, s.height, content)108+ localX := mouse.X - contentX109+ localY := mouse.Y - contentY110+ if localX < 0 || localY < 0 {111+ return s, nil, nil, false112+ }113+114+ metrics := createMetrics(s.create, contentWidth, contentHeight)115+ if metrics.Settings.Elo.contains(localX, localY) {116+ s.create.focus = createFocusElo117+ s.create.eloInput.SetCursor(createFieldCursor(localX, metrics.Settings.Elo, "Elo"))118+ next, cmd, action := s.syncFocus()119+ return next, cmd, action, true120+ }121+ if metrics.Settings.Seed.contains(localX, localY) {122+ if !s.create.seedEnabled() {123+ return s, nil, nil, true124+ }125+ s.create.focus = createFocusSeed126+ s.create.seedInput.SetCursor(createFieldCursor(localX, metrics.Settings.Seed, "Seed"))127+ next, cmd, action := s.syncFocus()128+ return next, cmd, action, true129+ }130+ if metrics.SummaryButton.contains(localX, localY) {131+ s.create.focus = createFocusGenerate132+ next, cmd, action := s.syncFocus()133+ if s.create.selectedCount() == 0 {134+ return next, cmd, action, true135+ }136+ return next, cmd, createGenerateAction{create: s.create}, true137+ }138+ if index, ok := createTreeIndexAt(localX, localY, metrics, s.create); ok {139+ s.create.focus = createFocusTree140+ s.create.cursor = index141+ next, cmd, action := s.syncFocus()142+ nextScreen := next.(createScreen)143+ return nextScreen.handleEnterWithHandled(cmd, action)144+ }145+ return s, nil, nil, false146+}147+148+func (s createScreen) handleEnterWithHandled(cmd tea.Cmd, action screenAction) (screenModel, tea.Cmd, screenAction, bool) {149+ next, enterCmd, enterAction := s.handleEnter()150+ return next, tea.Batch(cmd, enterCmd), firstScreenAction(action, enterAction), true151+}152+153+func firstScreenAction(actions ...screenAction) screenAction {154+ for _, action := range actions {155+ if action != nil {156+ return action157+ }158+ }159+ return nil160+}161+162+func createFieldCursor(localX int, rect createRect, label string) int {163+ labelWidth := lipgloss.Width(label) + 1164+ return max(localX-rect.X-2-labelWidth, 0)165+}166+167+func createPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {168+ panel := ui.Panel("Create", content, createFooterHint)169+ panelWidth := lipgloss.Width(panel)170+ panelHeight := lipgloss.Height(panel)171+ panelX = max((screenWidth-panelWidth)/2, 0)172+ panelY = max((screenHeight-panelHeight)/2, 0)173+174+ frame := ui.PanelFrame()175+ contentX = panelX + frame.GetHorizontalFrameSize()/2176+ contentY = panelY + 1 + 2 + 2177+ return panelX, panelY, contentX, contentY178+}179+180+func (s createScreen) handleEnter() (screenModel, tea.Cmd, screenAction) {181+ if s.create.focus == createFocusGenerate {182+ if s.create.selectedCount() == 0 {183+ return s, nil, nil184+ }185+ return s, nil, createGenerateAction{create: s.create}186+ }187+ if s.create.focus != createFocusTree {188+ return s, nil, nil189+ }190+191+ visible := s.create.visibleNodes()192+ if s.create.cursor < 0 || s.create.cursor >= len(visible) {193+ return s, nil, nil194+ }195+ node := visible[s.create.cursor].node196+ if node.leaf == nil {197+ s.create.expanded[node.id] = !s.create.expanded[node.id]198+ return s, nil, nil199+ }200+ s.toggleLeaf(node)201+ return s, nil, nil202+}203+204+func (s createScreen) nextFocus(delta int) createFocus {205+ focuses := []createFocus{createFocusTree, createFocusElo, createFocusGenerate}206+ if s.create.seedEnabled() {207+ focuses = []createFocus{createFocusTree, createFocusElo, createFocusSeed, createFocusGenerate}208+ }209+210+ current := 0211+ for i, focus := range focuses {212+ if focus == s.create.focus {213+ current = i214+ break215+ }216+ }217+ next := current + delta218+ for next < 0 {219+ next += len(focuses)220+ }221+ return focuses[next%len(focuses)]222+}223+224+func (s createScreen) syncFocus() (screenModel, tea.Cmd, screenAction) {225+ s.create.eloInput.Blur()226+ s.create.seedInput.Blur()227+ switch s.create.focus {228+ case createFocusElo:229+ return s, s.create.eloInput.Focus(), nil230+ case createFocusSeed:231+ if s.create.seedEnabled() {232+ return s, s.create.seedInput.Focus(), nil233+ }234+ s.create.focus = createFocusTree235+ return s, nil, nil236+ case createFocusGenerate:237+ return s, nil, nil238+ default:239+ return s, nil, nil240+ }241+}242+243+func (s createScreen) updateFocusedInput(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {244+ var cmd tea.Cmd245+ switch s.create.focus {246+ case createFocusElo:247+ s.create.eloInput, cmd = s.create.eloInput.Update(msg)248+ case createFocusSeed:249+ if s.create.seedEnabled() {250+ s.create.seedInput, cmd = s.create.seedInput.Update(msg)251+ }252+ }253+ return s, cmd, nil254+}255+256+func (s *createScreen) moveCursor(delta int) {257+ visible := s.create.visibleNodes()258+ if len(visible) == 0 {259+ s.create.cursor = 0260+ return261+ }262+ s.create.cursor += delta263+ if s.create.cursor < 0 {264+ s.create.cursor = len(visible) - 1265+ }266+ if s.create.cursor >= len(visible) {267+ s.create.cursor = 0268+ }269+}270+271+func (s *createScreen) toggleCurrentTreeNode() {272+ visible := s.create.visibleNodes()273+ if s.create.cursor < 0 || s.create.cursor >= len(visible) {274+ return275+ }276+ node := visible[s.create.cursor].node277+ if node.leaf == nil {278+ s.create.toggleDescendantLeaves(node)279+ s.afterCreateSelectionChanged()280+ return281+ }282+ s.toggleLeaf(node)283+}284+285+func (s *createScreen) toggleLeaf(node createTreeNode) {286+ if node.leaf == nil {287+ return288+ }289+ s.create.checked[node.leaf.id] = !s.create.checked[node.leaf.id]290+ s.afterCreateSelectionChanged()291+}292+293+func (s *createScreen) afterCreateSelectionChanged() {294+ if !s.create.seedEnabled() {295+ s.create.seedInput.Blur()296+ if s.create.focus == createFocusSeed {297+ s.create.focus = createFocusTree298+ }299+ }300+}301+302+func (s createScreen) View(notice noticeState) string {303+ return renderPanelView(304+ s.width,305+ s.height,306+ notice,307+ "Create",308+ createBody(s.create, s.width, s.height),309+ createFooterHint,310+ )311+}312+313+func createBody(state createState, width, height int) string {314+ contentWidth, contentHeight := createContentBounds(width, height)315+ treeWidth, sideWidth := createLayoutWidths(contentWidth)316+ settings := createSettingsPanel(state, contentWidth)317+ tree := createTreePanel(state, treeWidth, contentHeight)318+ sidebar := createPoolPanel(state, sideWidth, contentHeight)319+ summary := createSummaryPanel(state, contentWidth)320+321+ var middle string322+ if contentWidth < 88 {323+ middle = lipgloss.JoinVertical(lipgloss.Left, tree, sidebar)324+ } else {325+ middle = lipgloss.JoinHorizontal(lipgloss.Top, tree, " ", sidebar)326+ }327+ return lipgloss.JoinVertical(lipgloss.Left, settings, middle, summary)328+}329+330+func createContentBounds(width, height int) (contentWidth, contentHeight int) {331+ contentWidth = max(width-14, 24)332+ contentHeight = max(height-14, 10)333+ return contentWidth, contentHeight334+}335+336+func createLayoutWidths(contentWidth int) (treeWidth, sideWidth int) {337+ sideWidth = min(42, max(30, contentWidth/3))338+ treeWidth = max(24, contentWidth-sideWidth-3)339+ return treeWidth, sideWidth340+}341+342+func createMetrics(state createState, width, height int) createContentMetrics {343+ settings := createSettingsRectsForWidth(state, width)344+ treeWidth, sideWidth := createLayoutWidths(width)345+ tree := createTreePanel(state, treeWidth, height)346+ pool := createPoolPanel(state, sideWidth, height)347+ summary := createSummaryPanel(state, width)348+ treeHeight := lipgloss.Height(tree)349+ poolHeight := lipgloss.Height(pool)350+ summaryHeight := lipgloss.Height(summary)351+352+ treeY := settings.TotalHeight353+ poolX := 0354+ poolY := treeY + treeHeight355+ if width >= 88 {356+ poolX = treeWidth + 3357+ poolY = treeY358+ }359+ summaryY := settings.TotalHeight + treeHeight + poolHeight360+ if width >= 88 {361+ summaryY = settings.TotalHeight + max(treeHeight, poolHeight)362+ }363+364+ button := renderCreateGenerateChip(state)365+ buttonWidth := lipgloss.Width(button)366+ start, end := createVisibleWindow(state, height)367+ return createContentMetrics{368+ Width: width,369+ Height: height,370+ Settings: settings,371+ Tree: createRect{X: 0, Y: treeY, W: treeWidth, H: treeHeight},372+ TreeStart: start,373+ TreeEnd: end,374+ Pool: createRect{X: poolX, Y: poolY, W: sideWidth, H: poolHeight},375+ Summary: createRect{X: 0, Y: summaryY, W: width, H: summaryHeight},376+ SummaryButton: createRect{377+ X: max(width-buttonWidth, 0),378+ Y: summaryY,379+ W: buttonWidth,380+ H: summaryHeight,381+ },382+ }383+}384+385+func createSettingsRectsForWidth(state createState, width int) createSettingsRects {386+ inlineHeight := lipgloss.Height(createInlineField("Elo", state.eloInput.View(), state.focus == createFocusElo, max(width, 18)))387+ if width < 52 {388+ return createSettingsRects{389+ Elo: createRect{X: 0, Y: 0, W: width, H: inlineHeight},390+ Seed: createRect{X: 0, Y: inlineHeight, W: width, H: inlineHeight},391+ TotalHeight: inlineHeight * 2,392+ }393+ }394+395+ eloWidth := 18396+ seedWidth := max(width-eloWidth-2, 24)397+ return createSettingsRects{398+ Elo: createRect{X: 0, Y: 0, W: eloWidth, H: inlineHeight},399+ Seed: createRect{X: eloWidth + 2, Y: 0, W: seedWidth, H: inlineHeight},400+ TotalHeight: inlineHeight,401+ }402+}403+404+func createVisibleWindow(state createState, height int) (start, end int) {405+ visible := state.visibleNodes()406+ if len(visible) == 0 {407+ return 0, 0408+ }409+ maxRows := max(8, height-9)410+ if len(visible) <= maxRows {411+ return 0, len(visible)412+ }413+ start = min(max(0, state.cursor-maxRows+1), len(visible)-maxRows)414+ return start, start + maxRows415+}416+417+func createTreeIndexAt(localX, localY int, metrics createContentMetrics, state createState) (int, bool) {418+ if !metrics.Tree.contains(localX, localY) {419+ return 0, false420+ }421+ row := localY - metrics.Tree.Y - 2422+ if row < 0 || row >= metrics.TreeEnd-metrics.TreeStart {423+ return 0, false424+ }425+ visible := state.visibleNodes()426+ if metrics.TreeStart > 0 && row == 0 {427+ return 0, false428+ }429+ if metrics.TreeEnd < len(visible) && row == metrics.TreeEnd-metrics.TreeStart-1 {430+ return 0, false431+ }432+ index := metrics.TreeStart + row433+ if index < 0 || index >= len(visible) {434+ return 0, false435+ }436+ return index, true437+}438+439+func createTreePanel(state createState, width, height int) string {440+ lines := make([]string, 0)441+ for i, item := range state.visibleNodes() {442+ lines = append(lines, renderCreateTreeLine(state, item, i == state.cursor))443+ }444+ if len(lines) == 0 {445+ lines = append(lines, "No Elo-capable games available.")446+ }447+448+ start, end := createVisibleWindow(state, height)449+ if len(state.visibleNodes()) > 0 && len(lines) > end-start {450+ lines = lines[start:end]451+ if start > 0 {452+ lines[0] = ui.DimItemStyle().Render("...")453+ }454+ if end < len(state.visibleNodes()) {455+ lines[len(lines)-1] = ui.DimItemStyle().Render("...")456+ }457+ }458+459+ return createSection("Puzzle Pool", strings.Join(lines, "\n"), state.focus == createFocusTree, width)460+}461+462+func createSettingsPanel(state createState, width int) string {463+ seed := state.seedInput.View()464+ if !state.seedEnabled() {465+ seed = "disabled for multi-select"466+ }467+468+ if width < 52 {469+ return lipgloss.JoinVertical(lipgloss.Left,470+ createInlineField("Elo", state.eloInput.View(), state.focus == createFocusElo, width),471+ createInlineTextField("Seed", seed, state.focus == createFocusSeed && state.seedEnabled(), !state.seedEnabled(), width),472+ )473+ }474+475+ eloWidth := 18476+ seedWidth := max(width-eloWidth-2, 24)477+ return lipgloss.JoinHorizontal(lipgloss.Top,478+ createInlineField("Elo", state.eloInput.View(), state.focus == createFocusElo, eloWidth),479+ " ",480+ createInlineTextField("Seed", seed, state.focus == createFocusSeed && state.seedEnabled(), !state.seedEnabled(), seedWidth),481+ )482+}483+484+func createPoolPanel(state createState, width, height int) string {485+ body := lipgloss.JoinVertical(486+ lipgloss.Left,487+ ui.DimItemStyle().Render(strconv.Itoa(state.selectedCount())+" selected"),488+ renderSelectedLeafSummary(state, width-4, max(3, height-7)),489+ )490+ return createSection("Selected", body, false, width)491+}492+493+func createSummaryPanel(state createState, width int) string {494+ totalText := ui.PanelTitle().Render("Total: " + strconv.Itoa(state.selectedCount()))495+ selected := selectedCreateDetail(state)496+ button := renderCreateGenerateChip(state)497+ detailWidth := max(width-lipgloss.Width(totalText)-lipgloss.Width(button)-4, 12)498+ detail := lipgloss.NewStyle().499+ Width(detailWidth).500+ MaxWidth(detailWidth).501+ Render(ui.DimItemStyle().Render(truncatePlainCreateLine(selected, detailWidth)))502+ return lipgloss.JoinHorizontal(lipgloss.Top, totalText, " ", detail, " ", button)503+}504+505+func renderSelectedLeafSummary(state createState, width, maxRows int) string {506+ leaves := state.selectedLeaves()507+ if len(leaves) == 0 {508+ return ui.DimItemStyle().Render("No leaves selected")509+ }510+511+ limit := min(len(leaves), maxRows)512+ lines := make([]string, 0, limit+1)513+ for _, leaf := range leaves[:limit] {514+ lines = append(lines, ui.DimItemStyle().Render(truncatePlainCreateLine(leaf.gameType+" / "+leaf.title, width)))515+ }516+ if len(leaves) > limit {517+ lines = append(lines, ui.DimItemStyle().Render("+"+strconv.Itoa(len(leaves)-limit)+" more"))518+ }519+ return strings.Join(lines, "\n")520+}521+522+func selectedCreateDetail(state createState) string {523+ leaves := state.selectedLeaves()524+ if len(leaves) == 0 {525+ return "Selected: none"526+ }527+ if len(leaves) == 1 {528+ return "Selected: " + leaves[0].gameType + " / " + leaves[0].title529+ }530+ return "Selected: random from checked pool"531+}532+533+func renderCreateGenerateChip(state createState) string {534+ if state.selectedCount() == 0 {535+ return ui.DimItemStyle().Render(renderChoiceChip("Generate", false))536+ }537+ return renderChoiceChip("Generate", state.focus == createFocusGenerate)538+}539+540+func renderCreateTreeLine(state createState, item createVisibleNode, cursor bool) string {541+ node := item.node542+ prefix := strings.Repeat(" ", item.depth)543+ cursorText := " "544+ if cursor && state.focus == createFocusTree {545+ cursorText = ui.CursorStyle().Render("> ")546+ }547+ if node.leaf == nil {548+ selected, total := state.descendantLeafCounts(node)549+ marker := "[ ] "550+ if total > 0 && selected == total {551+ marker = "[x] "552+ } else if selected > 0 {553+ marker = "[-] "554+ }555+ count := ""556+ if total > 1 {557+ count = " " + ui.DimItemStyle().Render(strconv.Itoa(selected)+"/"+strconv.Itoa(total))558+ }559+ return cursorText + prefix + marker + node.title + count560+ }561+562+ box := "[ ] "563+ if state.checked[node.leaf.id] {564+ box = "[x] "565+ }566+ return cursorText + prefix + box + node.title567+}568+569+func createInlineField(label, value string, focused bool, width int) string {570+ return createInlineTextField(label, value, focused, false, width)571+}572+573+func createInlineTextField(label, value string, focused, dimmed bool, width int) string {574+ border := theme.Current().Border575+ if focused {576+ border = theme.Current().Accent577+ }578+579+ innerWidth := max(width-4, 1)580+ labelWidth := lipgloss.Width(label) + 1581+ valueStyle := lipgloss.NewStyle().Foreground(theme.Current().FG)582+ if dimmed {583+ valueStyle = ui.DimItemStyle()584+ }585+ if focused {586+ valueStyle = valueStyle.Foreground(theme.Current().Accent).Bold(true)587+ }588+ body := lipgloss.JoinHorizontal(589+ lipgloss.Top,590+ ui.DimItemStyle().Render(label),591+ " ",592+ renderSingleLine(valueStyle.Render(value), innerWidth-labelWidth),593+ )594+ return lipgloss.NewStyle().595+ Border(lipgloss.RoundedBorder()).596+ BorderForeground(border).597+ Padding(0, 1).598+ Width(width).599+ Height(1).600+ Render(body)601+}602+603+func createSection(title, content string, focused bool, width int) string {604+ border := theme.Current().Border605+ titleStyle := ui.PanelTitle()606+ if focused {607+ border = theme.Current().Accent608+ titleStyle = ui.SelectedItemStyle()609+ }610+ body := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(title), content)611+ return lipgloss.NewStyle().612+ Border(lipgloss.RoundedBorder()).613+ BorderForeground(border).614+ Padding(0, 1).615+ Width(width).616+ Render(body)617+}618+619+func truncatePlainCreateLine(s string, width int) string {620+ if width <= 1 || len(s) <= width {621+ return s622+ }623+ return s[:width-1] + "…"624+}625+626+type createGenerateAction struct {627+ create createState628+}629+630+type createBackAction struct {631+ create createState632+}633+634+func (a createBackAction) applyToModel(m model) (model, tea.Cmd) {635+ m.create = a.create636+ m = m.persistCreatePresetIfValid()637+ m.state = playMenuView638+ return m.resizeActiveScreen(), nil639+}640+641+func (a createGenerateAction) applyToModel(m model) (model, tea.Cmd) {642+ m.create = a.create643+ return m.handleCreateGenerate()644+}645+646+func normalizedCreateSeed(value string) string {647+ return sessionflow.NormalizeSeed(value)648+}
A app/screen_create_test.go +297 -0
@@ -0,0 +1,297 @@
1+package app2+3+import (4+ "strings"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/config"8+9+ tea "charm.land/bubbletea/v2"10+ "github.com/charmbracelet/x/ansi"11+)12+13+func TestCreateScreenSpaceTogglesParentDescendantsAndLeaves(t *testing.T) {14+ state := newCreateState(config.CreateConfig{}, 80)15+ parentIndex := visibleNodeIndexByTitle(t, state, "Sudoku")16+ state.cursor = parentIndex17+ screen := createScreen{create: state}18+ parent := state.visibleNodes()[parentIndex].node19+ _, parentTotal := state.descendantLeafCounts(parent)20+21+ next, _, _ := screen.Update(tea.KeyPressMsg{Code: tea.KeySpace})22+ got := next.(createScreen)23+ if got.create.selectedCount() != parentTotal {24+ t.Fatalf("selected count after parent space = %d, want %d", got.create.selectedCount(), parentTotal)25+ }26+27+ got.create.expanded[parent.id] = true28+ got.create.cursor = visibleLeafIndexByID(t, got.create, firstDescendantLeafID(t, parent.children[0]))29+ next, _, _ = got.Update(tea.KeyPressMsg{Code: tea.KeySpace})30+ got = next.(createScreen)31+ if got.create.selectedCount() != parentTotal-1 {32+ t.Fatalf("selected count after leaf space = %d, want %d", got.create.selectedCount(), parentTotal-1)33+ }34+}35+36+func TestCreateScreenEnterExpandsAndCollapsesParent(t *testing.T) {37+ state := newCreateState(config.CreateConfig{}, 80)38+ state.cursor = visibleNodeIndexByTitle(t, state, "Sudoku")39+ screen := createScreen{create: state}40+ parentID := screen.create.visibleNodes()[state.cursor].node.id41+42+ next, _, _ := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})43+ expanded := next.(createScreen)44+ if !expanded.create.expanded[parentID] {45+ t.Fatal("expected parent to expand")46+ }47+ if expanded.create.selectedCount() != 0 {48+ t.Fatalf("selected count after parent enter = %d, want 0", expanded.create.selectedCount())49+ }50+51+ next, _, _ = expanded.Update(tea.KeyPressMsg{Code: tea.KeyEnter})52+ collapsed := next.(createScreen)53+ if collapsed.create.expanded[parentID] {54+ t.Fatal("expected parent to collapse")55+ }56+}57+58+func TestCreateScreenGenerateButtonProducesAction(t *testing.T) {59+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/nonogram"}}, 80)60+ state.focus = createFocusGenerate61+ screen := createScreen{create: state}62+63+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})64+ if _, ok := action.(createGenerateAction); !ok {65+ t.Fatalf("action = %T, want createGenerateAction", action)66+ }67+}68+69+func TestCreateScreenGenerateButtonDisabledWithoutSelection(t *testing.T) {70+ state := newCreateState(config.CreateConfig{Elo: 1200}, 80)71+ state.focus = createFocusGenerate72+ screen := createScreen{create: state}73+74+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})75+ if action != nil {76+ t.Fatalf("action = %T, want nil", action)77+ }78+}79+80+func TestCreateScreenMouseClickFocusesFields(t *testing.T) {81+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/nonogram"}}, 120)82+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)83+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)84+ metrics := createMetrics(screen.create, contentWidth, contentHeight)85+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))86+87+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{88+ X: contentX + metrics.Settings.Seed.X + 8,89+ Y: contentY + metrics.Settings.Seed.Y + 1,90+ }, contentWidth, contentHeight)91+ if !handled {92+ t.Fatal("expected seed field click to be handled")93+ }94+ screen = next.(createScreen)95+ if screen.create.focus != createFocusSeed {96+ t.Fatalf("focus = %v, want createFocusSeed", screen.create.focus)97+ }98+99+ metrics = createMetrics(screen.create, contentWidth, contentHeight)100+ next, _, _, handled = screen.handleMouseClick(tea.MouseClickMsg{101+ X: contentX + metrics.Settings.Elo.X + 7,102+ Y: contentY + metrics.Settings.Elo.Y + 1,103+ }, contentWidth, contentHeight)104+ if !handled {105+ t.Fatal("expected elo field click to be handled")106+ }107+ screen = next.(createScreen)108+ if screen.create.focus != createFocusElo {109+ t.Fatalf("focus = %v, want createFocusElo", screen.create.focus)110+ }111+}112+113+func TestCreateScreenMouseClickDisabledSeedDoesNotFocusSeed(t *testing.T) {114+ state := newCreateState(config.CreateConfig{115+ SelectedLeafIDs: []string{"nonogram/nonogram", "netwalk/netwalk"},116+ }, 120)117+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)118+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)119+ metrics := createMetrics(screen.create, contentWidth, contentHeight)120+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))121+122+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{123+ X: contentX + metrics.Settings.Seed.X + 8,124+ Y: contentY + metrics.Settings.Seed.Y + 1,125+ }, contentWidth, contentHeight)126+ if !handled {127+ t.Fatal("expected disabled seed field click to be handled")128+ }129+ got := next.(createScreen)130+ if got.create.focus == createFocusSeed {131+ t.Fatal("disabled seed click should not focus the seed input")132+ }133+}134+135+func TestCreateScreenMouseClickGenerateButtonProducesAction(t *testing.T) {136+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/nonogram"}}, 120)137+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)138+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)139+ metrics := createMetrics(screen.create, contentWidth, contentHeight)140+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))141+142+ _, _, action, handled := screen.handleMouseClick(tea.MouseClickMsg{143+ X: contentX + metrics.SummaryButton.X + 1,144+ Y: contentY + metrics.SummaryButton.Y,145+ }, contentWidth, contentHeight)146+ if !handled {147+ t.Fatal("expected generate button click to be handled")148+ }149+ if _, ok := action.(createGenerateAction); !ok {150+ t.Fatalf("action = %T, want createGenerateAction", action)151+ }152+}153+154+func TestCreateScreenMouseClickTreeRowsSelectsAndToggles(t *testing.T) {155+ state := newCreateState(config.CreateConfig{}, 120)156+ parentID := state.visibleNodes()[0].node.id157+ state.expanded[parentID] = true158+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)159+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)160+ metrics := createMetrics(screen.create, contentWidth, contentHeight)161+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))162+163+ leafIndex := -1164+ for i, item := range screen.create.visibleNodes() {165+ if item.node.leaf != nil {166+ leafIndex = i167+ break168+ }169+ }170+ if leafIndex < 0 {171+ t.Fatal("test setup expected a visible leaf")172+ }173+ row := leafIndex - metrics.TreeStart174+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{175+ X: contentX + metrics.Tree.X + 4,176+ Y: contentY + metrics.Tree.Y + 2 + row,177+ }, contentWidth, contentHeight)178+ if !handled {179+ t.Fatal("expected tree leaf click to be handled")180+ }181+ got := next.(createScreen)182+ if got.create.focus != createFocusTree {183+ t.Fatalf("focus = %v, want createFocusTree", got.create.focus)184+ }185+ if got.create.cursor != leafIndex {186+ t.Fatalf("cursor = %d, want %d", got.create.cursor, leafIndex)187+ }188+ if got.create.selectedCount() != 1 {189+ t.Fatalf("selected count = %d, want 1", got.create.selectedCount())190+ }191+}192+193+func TestCreateScreenMouseClickTreeParentExpands(t *testing.T) {194+ state := newCreateState(config.CreateConfig{}, 120)195+ parentIndex := visibleNodeIndexByTitle(t, state, "Sudoku")196+ parentID := state.visibleNodes()[parentIndex].node.id197+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)198+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)199+ metrics := createMetrics(screen.create, contentWidth, contentHeight)200+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))201+202+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{203+ X: contentX + metrics.Tree.X + 4,204+ Y: contentY + metrics.Tree.Y + 2 + parentIndex,205+ }, contentWidth, contentHeight)206+ if !handled {207+ t.Fatal("expected tree parent click to be handled")208+ }209+ got := next.(createScreen)210+ if !got.create.expanded[parentID] {211+ t.Fatal("expected parent click to expand the tree row")212+ }213+}214+215+func TestCreateScreenRenderedParentRowsIncludeRollupCounts(t *testing.T) {216+ state := newCreateState(config.CreateConfig{}, 80)217+ sudoku := requireCreateNode(t, state.tree, "Sudoku")218+ state.checked[firstDescendantLeafID(t, sudoku.children[0])] = true219+220+ line := renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)221+ if !strings.Contains(line, "Sudoku") || !strings.Contains(line, "1/2") {222+ t.Fatalf("rendered line = %q, want Sudoku with 1/2 rollup", line)223+ }224+}225+226+func TestCreateScreenRenderedTopLevelLeafOmitsRollupCounts(t *testing.T) {227+ state := newCreateState(config.CreateConfig{}, 80)228+ nonogram := requireCreateNode(t, state.tree, "Nonogram")229+ state.checked[firstDescendantLeafID(t, nonogram)] = true230+231+ line := renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)232+ if strings.Contains(line, "1/1") || strings.Contains(line, "[-]") {233+ t.Fatalf("rendered line = %q, want selected leaf without rollup count", line)234+ }235+ if !strings.Contains(line, "[x] Nonogram") {236+ t.Fatalf("rendered line = %q, want checked top-level leaf", line)237+ }238+}239+240+func TestCreateScreenRenderedParentRowsSignalSelectionState(t *testing.T) {241+ state := newCreateState(config.CreateConfig{}, 80)242+ sudoku := requireCreateNode(t, state.tree, "Sudoku")243+244+ line := renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)245+ if !strings.Contains(line, "[ ] Sudoku") {246+ t.Fatalf("unchecked parent line = %q, want unchecked marker", line)247+ }248+249+ state.checked[firstDescendantLeafID(t, sudoku.children[0])] = true250+ line = renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)251+ if !strings.Contains(line, "[-] Sudoku") {252+ t.Fatalf("partial parent line = %q, want partial marker", line)253+ }254+255+ state.toggleDescendantLeaves(sudoku)256+ line = renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)257+ if !strings.Contains(line, "[x] Sudoku") {258+ t.Fatalf("selected parent line = %q, want checked marker", line)259+ }260+}261+262+func TestCreateScreenEloFieldDoesNotRenderPromptMarker(t *testing.T) {263+ state := newCreateState(config.CreateConfig{Elo: 1200}, 80)264+ state.focus = createFocusElo265+ screen, _, _ := (createScreen{create: state}).syncFocus()266+ state = screen.(createScreen).create267+268+ settings := ansi.Strip(createSettingsPanel(state, 80))269+ if strings.Contains(settings, "> 1200") || strings.Contains(settings, ">1200") {270+ t.Fatalf("settings rendered Elo prompt marker: %q", settings)271+ }272+ if got := state.eloInput.Value(); got != "1200" {273+ t.Fatalf("elo input value = %q, want 1200", got)274+ }275+}276+277+func visibleNodeIndexByTitle(t *testing.T, state createState, title string) int {278+ t.Helper()279+ for i, item := range state.visibleNodes() {280+ if item.node.title == title {281+ return i282+ }283+ }284+ t.Fatalf("missing visible node %q", title)285+ return 0286+}287+288+func visibleLeafIndexByID(t *testing.T, state createState, id string) int {289+ t.Helper()290+ for i, item := range state.visibleNodes() {291+ if item.node.leaf != nil && item.node.leaf.id == id {292+ return i293+ }294+ }295+ t.Fatalf("missing visible leaf %q", id)296+ return 0297+}
M app/screen_menu.go +1 -3
M app/screen_navigation.go +2 -2
M app/screens_test.go +4 -4
M app/seed_input.go +6 -18
M app/session_controller.go +25 -1
@@ -4,6 +4,7 @@ import (
44 "context"55 "math/rand/v2"66 7+ "github.com/FelineStateMachine/puzzletea/difficulty"78 "github.com/FelineStateMachine/puzzletea/game"89 sessionflow "github.com/FelineStateMachine/puzzletea/session"910 "github.com/FelineStateMachine/puzzletea/store"
@@ -50,6 +51,15 @@ func (c sessionController) startSpawn(spawner game.Spawner, request spawnRequest
5051 return tea.Batch(c.model.spinner.Tick, spawnCmd(spawner, ctx, jobID))5152 }5253 54+func (c sessionController) startEloSpawn(spawner game.EloSpawner, seed string, elo difficulty.Elo, request spawnRequest) tea.Cmd {55+ ctx, jobID := c.beginSpawnContext()56+ c.model.session.spawn = &request57+ c.model.state = generatingView58+ *c.model = c.model.clearNotice()59+ *c.model = c.model.initScreen(generatingView)60+ return tea.Batch(c.model.spinner.Tick, spawnEloCmd(spawner, seed, elo, ctx, jobID))61+}62+5363 func (c sessionController) startSeededSpawn(spawner game.SeededSpawner, rng *rand.Rand, request spawnRequest) tea.Cmd {5464 ctx, jobID := c.beginSpawnContext()5565 c.model.session.spawn = &request
@@ -95,13 +105,14 @@ func (c sessionController) handleSpawnComplete(jobID int64, msg game.SpawnComple
95105 })96106 *c.model = c.model.clearNotice()97107 98- rec, err := sessionflow.CreateRecord(108+ rec, err := sessionflow.CreateRecordWithDifficulty(99109 c.model.store,100110 c.model.session.game,101111 request.name,102112 request.gameType,103113 request.modeTitle,104114 request.run,115+ difficultyMetadataFromSpawnReport(msg.Report),105116 )106117 if err != nil {107118 *c.model = c.model.setErrorf("Started puzzle, but could not create a save record: %v", err)
@@ -111,6 +122,19 @@ func (c sessionController) handleSpawnComplete(jobID int64, msg game.SpawnComple
111122 return nil112123 }113124 125+func difficultyMetadataFromSpawnReport(report difficulty.Report) sessionflow.DifficultyMetadata {126+ if report.Confidence == "" {127+ return sessionflow.DifficultyMetadata{}128+ }129+ target := report.TargetElo130+ actual := report.ActualElo131+ return sessionflow.DifficultyMetadata{132+ TargetElo: &target,133+ ActualElo: &actual,134+ Confidence: report.Confidence,135+ }136+}137+114138 func (c sessionController) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) {115139 g, _ = g.Update(game.HelpToggleMsg{Show: c.model.help.showFull})116140 g, _ = g.Update(tea.WindowSizeMsg{Width: c.model.width, Height: c.model.height})
M app/spawn.go +20 -0
M app/view.go +5 -5
M app/view_test.go +17 -0
M catalog/catalog.go +28 -0
@@ -5,6 +5,7 @@ package catalog
55 import (66 "fmt"77 8+ "github.com/FelineStateMachine/puzzletea/difficulty"89 "github.com/FelineStateMachine/puzzletea/puzzle"910 )1011
@@ -95,6 +96,33 @@ func Validate(definitions []puzzle.Definition) error {
9596 }9697 modeTitles[mode.ID] = struct{}{}9798 }99+ variantIDs := make(map[puzzle.VariantID]struct{}, len(def.Variants))100+ for _, variant := range def.Variants {101+ if variant.ID == "" {102+ return fmt.Errorf("game %q has variant %q with empty id", def.Name, variant.Title)103+ }104+ if err := difficulty.ValidateElo(variant.DefaultElo); err != nil {105+ return fmt.Errorf("game %q variant %q default Elo is invalid: %w", def.Name, variant.Title, err)106+ }107+ if _, exists := variantIDs[variant.ID]; exists {108+ return fmt.Errorf("game %q has duplicate variant %q", def.Name, variant.ID)109+ }110+ variantIDs[variant.ID] = struct{}{}111+ }112+ for _, alias := range def.LegacyModes {113+ if alias.ID == "" {114+ return fmt.Errorf("game %q has legacy mode %q with empty id", def.Name, alias.Title)115+ }116+ if _, ok := variantIDs[alias.TargetVariantID]; !ok {117+ return fmt.Errorf("game %q legacy mode %q targets missing variant %q", def.Name, alias.Title, alias.TargetVariantID)118+ }119+ if err := difficulty.ValidateElo(alias.PresetElo); err != nil {120+ return fmt.Errorf("game %q legacy mode %q preset Elo is invalid: %w", def.Name, alias.Title, err)121+ }122+ if alias.XPWeight < 1 {123+ return fmt.Errorf("game %q legacy mode %q XP weight must be positive", def.Name, alias.Title)124+ }125+ }98126 99127 for _, dailyID := range def.DailyModeIDs {100128 if _, ok := modeTitles[dailyID]; !ok {
M cmd/config_test.go +4 -0
M cmd/new.go +93 -14
@@ -5,6 +5,7 @@ import (
55 "strings"66 77 "github.com/FelineStateMachine/puzzletea/config"8+ "github.com/FelineStateMachine/puzzletea/difficulty"89 "github.com/FelineStateMachine/puzzletea/game"910 "github.com/FelineStateMachine/puzzletea/registry"1011 "github.com/FelineStateMachine/puzzletea/resolve"
@@ -15,10 +16,12 @@ import (
1516 )1617 1718 var (18- flagSetSeed string19- flagWithSeed string20- flagExport int21- flagOutput string19+ flagSetSeed string20+ flagWithSeed string21+ flagDifficulty int22+ flagDifficultySet bool23+ flagExport int24+ flagOutput string2225 )2326 2427 var newCmd = &cobra.Command{
@@ -29,6 +32,7 @@ var newCmd = &cobra.Command{
2932 strings.Join(registry.Names(), "\n ")),3033 Args: cobra.RangeArgs(0, 2),3134 RunE: func(cmd *cobra.Command, args []string) error {35+ flagDifficultySet = cmd.Flags().Changed("difficulty")3236 if flagOutput != "" || cmd.Flags().Changed("export") {3337 return runNewExport(cmd, args)3438 }
@@ -56,6 +60,7 @@ var newCmd = &cobra.Command{
5660 func init() {5761 newCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation")5862 newCmd.Flags().StringVar(&flagWithSeed, "with-seed", "", "seed string for deterministic puzzle generation within the selected game/mode")63+ newCmd.Flags().IntVar(&flagDifficulty, "difficulty", -1, "target Elo difficulty for Elo-capable generation (0..3000)")5964 newCmd.Flags().IntVarP(&flagExport, "export", "e", 1, "number of puzzles to export")6065 newCmd.Flags().StringVarP(&flagOutput, "output", "o", "", "write puzzles to a jsonl file (defaults to stdout)")6166 }
@@ -68,14 +73,17 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
6873 return err6974 }7075 71- spawner, modeTitle, err := resolve.Mode(entry, modeArg)76+ selection, err := resolve.VariantEntry(entry, modeArg)7277 if err != nil {7378 return err7479 }7580 76- g, err := spawnFromMode(spawner, seed)81+ targetElo, err := difficultyFlag()7782 if err != nil {78- return fmt.Errorf("failed to spawn game: %w", err)83+ return err84+ }85+ if targetElo == nil {86+ targetElo = selection.ExplicitElo7987 }8088 8189 s, err := openStoreFn(cfg.DBPath)
@@ -85,9 +93,15 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
8593 defer s.Close()8694 8795 name := sessionflow.GenerateUniqueName(s)96+97+ g, report, err := spawnFromVariant(selection.Variant, seed, targetElo, name)98+ if err != nil {99+ return fmt.Errorf("failed to spawn game: %w", err)100+ }88101 g = g.SetTitle(name)89102 90- rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle, store.NormalRunMetadata())103+ meta := difficultyMetadataFromReport(report)104+ rec, err := sessionflow.CreateRecordWithDifficulty(s, g, name, entry.Definition.Name, selection.DisplayTitle, store.NormalRunMetadata(), meta)91105 if err != nil {92106 return err93107 }
@@ -95,16 +109,81 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
95109 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)96110 }97111 98-func spawnFromMode(spawner game.Spawner, seed string) (game.Gamer, error) {112+func spawnFromVariant(variant registry.VariantEntry, seed string, targetElo *difficulty.Elo, fallbackEloSeed string) (game.Gamer, difficulty.Report, error) {113+ effectiveElo := targetElo114+ if effectiveElo == nil {115+ elo := variant.Definition.DefaultElo116+ effectiveElo = &elo117+ }118+ if variant.Elo == nil {119+ return nil, difficulty.Report{}, fmt.Errorf("variant does not support Elo difficulty")120+ }121+ eloSeed := seed122+ if eloSeed == "" {123+ eloSeed = fallbackEloSeed124+ }125+ g, report, err := variant.Elo.SpawnElo(eloSeed, *effectiveElo)126+ if err != nil {127+ return nil, difficulty.Report{}, err128+ }129+ return g, report, nil130+}131+132+func spawnFromMode(mode registry.ModeEntry, seed string, targetElo *difficulty.Elo, fallbackEloSeed string) (game.Gamer, difficulty.Report, error) {133+ effectiveElo := targetElo134+ if effectiveElo == nil {135+ effectiveElo = mode.Definition.PresetElo136+ }137+138+ if effectiveElo != nil {139+ if mode.Elo == nil {140+ return nil, difficulty.Report{}, fmt.Errorf("mode does not support Elo difficulty")141+ }142+ eloSeed := seed143+ if eloSeed == "" {144+ eloSeed = fallbackEloSeed145+ }146+ g, report, err := mode.Elo.SpawnElo(eloSeed, *effectiveElo)147+ if err != nil {148+ return nil, difficulty.Report{}, err149+ }150+ return g, report, nil151+ }152+99153 if seed == "" {100- return spawner.Spawn()154+ g, err := mode.Spawner.Spawn()155+ return g, difficulty.Report{}, err101156 }102157 103- seeded, ok := spawner.(game.SeededSpawner)104- if !ok {105- return nil, fmt.Errorf("mode does not support deterministic spawning")158+ if mode.Seeded == nil {159+ return nil, difficulty.Report{}, fmt.Errorf("mode does not support deterministic spawning")160+ }161+ g, err := mode.Seeded.SpawnSeeded(resolve.RNGFromString(seed))162+ return g, difficulty.Report{}, err163+}164+165+func difficultyFlag() (*difficulty.Elo, error) {166+ if !flagDifficultySet {167+ return nil, nil168+ }169+ elo := difficulty.Elo(flagDifficulty)170+ if err := difficulty.ValidateElo(elo); err != nil {171+ return nil, err172+ }173+ return &elo, nil174+}175+176+func difficultyMetadataFromReport(report difficulty.Report) sessionflow.DifficultyMetadata {177+ if report.Confidence == "" {178+ return sessionflow.DifficultyMetadata{}179+ }180+ target := report.TargetElo181+ actual := report.ActualElo182+ return sessionflow.DifficultyMetadata{183+ TargetElo: &target,184+ ActualElo: &actual,185+ Confidence: report.Confidence,106186 }107- return seeded.SpawnSeeded(resolve.RNGFromString(seed))108187 }109188 110189 // launchSeededGame uses an arbitrary seed string to deterministically select
M cmd/new_export.go +69 -25
@@ -8,6 +8,7 @@ import (
88 "strings"99 "time"1010 11+ "github.com/FelineStateMachine/puzzletea/difficulty"1112 "github.com/FelineStateMachine/puzzletea/export/builtinprint"1213 "github.com/FelineStateMachine/puzzletea/export/pack"1314 "github.com/FelineStateMachine/puzzletea/export/pdf"
@@ -22,12 +23,14 @@ import (
2223 var exportNow = time.Now2324 2425 type exportModeEntry struct {25- spawner game.Spawner26- mode string26+ elo game.EloSpawner27+ defaultElo difficulty.Elo28+ mode string2729 }2830 2931 func runNewExport(cmd *cobra.Command, args []string) error {3032 builtinprint.Register()33+ flagDifficultySet = cmd.Flags().Changed("difficulty")3134 3235 if err := validateNewExportFlags(cmd, args); err != nil {3336 return err
@@ -51,8 +54,13 @@ func runNewExport(cmd *cobra.Command, args []string) error {
5154 return err5255 }5356 57+ targetElo, err := difficultyFlag()58+ if err != nil {59+ return err60+ }61+5462 generatedAt := exportNow()55- records, err := buildExportRecords(entry.Definition.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)63+ records, err := buildExportRecords(entry.Definition.Name, modeSelection, entries, flagExport, flagWithSeed, targetElo, generatedAt)5664 if err != nil {5765 return err5866 }
@@ -85,25 +93,34 @@ func validateNewExportFlags(_ *cobra.Command, args []string) error {
8593 8694 func collectExportModes(entry registry.Entry, modeArg string) ([]exportModeEntry, string, error) {8795 if modeArg != "" {88- spawner, modeTitle, err := resolve.Mode(entry, modeArg)96+ selection, err := resolve.VariantEntry(entry, modeArg)8997 if err != nil {9098 return nil, "", err9199 }92- return []exportModeEntry{{spawner: spawner, mode: modeTitle}}, modeTitle, nil100+ defaultElo := selection.Variant.Definition.DefaultElo101+ if selection.ExplicitElo != nil {102+ defaultElo = *selection.ExplicitElo103+ }104+ return []exportModeEntry{{105+ elo: selection.Variant.Elo,106+ defaultElo: defaultElo,107+ mode: selection.Variant.Definition.Title,108+ }}, selection.Variant.Definition.Title, nil93109 }94110 95- entries := make([]exportModeEntry, 0, len(entry.Modes))96- for _, mode := range entry.Modes {111+ entries := make([]exportModeEntry, 0, len(entry.Variants))112+ for _, variant := range entry.Variants {97113 entries = append(entries, exportModeEntry{98- spawner: mode.Spawner,99- mode: mode.Definition.Title,114+ elo: variant.Elo,115+ defaultElo: variant.Definition.DefaultElo,116+ mode: variant.Definition.Title,100117 })101118 }102119 if len(entries) == 0 {103- return nil, "", fmt.Errorf("game %q has no exportable modes", entry.Definition.Name)120+ return nil, "", fmt.Errorf("game %q has no exportable variants", entry.Definition.Name)104121 }105122 106- return entries, "mixed modes", nil123+ return entries, "mixed variants", nil107124 }108125 109126 func buildExportRecords(
@@ -111,6 +128,7 @@ func buildExportRecords(
111128 entries []exportModeEntry,112129 count int,113130 seed string,131+ targetElo *difficulty.Elo,114132 generatedAt time.Time,115133 ) ([]pdfexport.JSONLRecord, error) {116134 var rng *rand.Rand
@@ -137,7 +155,8 @@ func buildExportRecords(
137155 entry = entries[modeIndex]138156 }139157 140- puzzle, err := spawnExportPuzzle(entry.spawner, rng)158+ eloSeed := scopedExportEloSeed(seed, generatedAt, gameType, entry.mode, i+1)159+ puzzle, report, err := spawnExportPuzzle(entry, rng, eloSeed, targetElo)141160 if err != nil {142161 return nil, fmt.Errorf("generate puzzle %d: %w", i+1, err)143162 }
@@ -151,7 +170,7 @@ func buildExportRecords(
151170 }152171 153172 records = append(records, pdfexport.JSONLRecord{154- Schema: pdfexport.ExportSchemaV1,173+ Schema: pdfexport.ExportSchemaV2,155174 Pack: pdfexport.JSONLPackMeta{156175 Generated: generatedAt.Format(time.RFC3339),157176 Version: Version,
@@ -161,11 +180,14 @@ func buildExportRecords(
161180 Seed: seed,162181 },163182 Puzzle: pdfexport.JSONLPuzzle{164- Index: i + 1,165- Name: namegen.GenerateSeeded(nameRNG),166- Game: gameType,167- Mode: entry.mode,168- Save: json.RawMessage(save),183+ Index: i + 1,184+ Name: namegen.GenerateSeeded(nameRNG),185+ Game: gameType,186+ Mode: entry.mode,187+ TargetDifficultyElo: intPtrFromEloReport(report.TargetElo, report.Confidence),188+ ActualDifficultyElo: intPtrFromEloReport(report.ActualElo, report.Confidence),189+ DifficultyConfidence: string(report.Confidence),190+ Save: json.RawMessage(save),169191 },170192 })171193 }
@@ -173,16 +195,38 @@ func buildExportRecords(
173195 return records, nil174196 }175197 176-func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) {177- if rng == nil {178- return spawner.Spawn()198+func scopedExportEloSeed(seed string, generatedAt time.Time, gameType, mode string, index int) string {199+ base := strings.TrimSpace(seed)200+ if base == "" {201+ base = generatedAt.Format(time.RFC3339Nano)179202 }203+ return fmt.Sprintf("export:%s:%s:%s:%d", base, gameType, mode, index)204+}180205 181- seeded, ok := spawner.(game.SeededSpawner)182- if !ok {183- return nil, fmt.Errorf("mode does not support deterministic spawning")206+func spawnExportPuzzle(entry exportModeEntry, rng *rand.Rand, seed string, targetElo *difficulty.Elo) (game.Gamer, difficulty.Report, error) {207+ effectiveElo := targetElo208+ if effectiveElo == nil {209+ effectiveElo = &entry.defaultElo210+ }211+ if entry.elo == nil {212+ return nil, difficulty.Report{}, fmt.Errorf("variant does not support Elo difficulty")213+ }214+ if rng != nil && strings.TrimSpace(seed) == "" {215+ seed = fmt.Sprintf("export-seeded:%016x:%016x", rng.Uint64(), rng.Uint64())216+ }217+ g, report, err := entry.elo.SpawnElo(seed, *effectiveElo)218+ if err != nil {219+ return nil, difficulty.Report{}, err220+ }221+ return g, report, nil222+}223+224+func intPtrFromEloReport(elo difficulty.Elo, confidence difficulty.Confidence) *int {225+ if confidence == "" {226+ return nil184227 }185- return seeded.SpawnSeeded(rng)228+ v := int(elo)229+ return &v186230 }187231 188232 func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {
M cmd/new_export_test.go +72 -3
@@ -53,8 +53,8 @@ func TestRunNewExportValidation(t *testing.T) {
5353 if err := json.Unmarshal([]byte(line), &record); err != nil {5454 t.Fatalf("line %d is not valid jsonl: %v", i+1, err)5555 }56- if record.Schema != pdfexport.ExportSchemaV1 {57- t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV1)56+ if record.Schema != pdfexport.ExportSchemaV2 {57+ t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV2)5858 }5959 }6060 })
@@ -151,7 +151,7 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) {
151151 if string(data) == "old" {152152 t.Fatal("expected output file to be overwritten")153153 }154- if !strings.Contains(string(data), pdfexport.ExportSchemaV1) {154+ if !strings.Contains(string(data), pdfexport.ExportSchemaV2) {155155 t.Fatal("expected jsonl export schema marker")156156 }157157 }
@@ -187,27 +187,96 @@ func TestRunNewExportOmitsPrintPayload(t *testing.T) {
187187 }188188 }189189 190+func TestRunNewExportUsesPresetEloMetadata(t *testing.T) {191+ withExportFlagReset(t)192+ flagExport = 1193+194+ cmd, out := newExportTestCmd(t, true)195+ if err := runNewExport(cmd, []string{"sudoku", "easy"}); err != nil {196+ t.Fatalf("expected sudoku export success, got error: %v", err)197+ }198+199+ record := decodeSingleExportRecord(t, out.String())200+ if record.Puzzle.TargetDifficultyElo == nil {201+ t.Fatal("expected target_difficulty_elo from preset")202+ }203+ if got, want := *record.Puzzle.TargetDifficultyElo, 600; got != want {204+ t.Fatalf("target_difficulty_elo = %d, want %d", got, want)205+ }206+ if record.Puzzle.ActualDifficultyElo == nil {207+ t.Fatal("expected actual_difficulty_elo from Elo report")208+ }209+ if record.Puzzle.DifficultyConfidence == "" {210+ t.Fatal("expected difficulty_confidence")211+ }212+}213+214+func TestRunNewExportExplicitDifficultyOverridesPreset(t *testing.T) {215+ withExportFlagReset(t)216+ flagExport = 1217+ flagDifficulty = 2100218+219+ cmd, out := newExportTestCmd(t, true)220+ cmd.Flags().Int("difficulty", -1, "")221+ if err := cmd.Flags().Set("difficulty", strconv.Itoa(flagDifficulty)); err != nil {222+ t.Fatal(err)223+ }224+225+ if err := runNewExport(cmd, []string{"sudoku", "easy"}); err != nil {226+ t.Fatalf("expected sudoku export success, got error: %v", err)227+ }228+229+ record := decodeSingleExportRecord(t, out.String())230+ if record.Puzzle.TargetDifficultyElo == nil {231+ t.Fatal("expected target_difficulty_elo from explicit difficulty")232+ }233+ if got, want := *record.Puzzle.TargetDifficultyElo, flagDifficulty; got != want {234+ t.Fatalf("target_difficulty_elo = %d, want %d", got, want)235+ }236+}237+190238 func withExportFlagReset(t *testing.T) {191239 t.Helper()192240 193241 prevSetSeed := flagSetSeed194242 prevWithSeed := flagWithSeed243+ prevDifficulty := flagDifficulty244+ prevDifficultySet := flagDifficultySet195245 prevExport := flagExport196246 prevOutput := flagOutput197247 198248 flagSetSeed = ""199249 flagWithSeed = ""250+ flagDifficulty = -1251+ flagDifficultySet = false200252 flagExport = 1201253 flagOutput = ""202254 203255 t.Cleanup(func() {204256 flagSetSeed = prevSetSeed205257 flagWithSeed = prevWithSeed258+ flagDifficulty = prevDifficulty259+ flagDifficultySet = prevDifficultySet206260 flagExport = prevExport207261 flagOutput = prevOutput208262 })209263 }210264 265+func decodeSingleExportRecord(t *testing.T, output string) pdfexport.JSONLRecord {266+ t.Helper()267+268+ lines := strings.Split(strings.TrimSpace(output), "\n")269+ if len(lines) != 1 {270+ t.Fatalf("jsonl lines = %d, want 1", len(lines))271+ }272+273+ var record pdfexport.JSONLRecord274+ if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {275+ t.Fatal(err)276+ }277+ return record278+}279+211280 func newExportTestCmd(t *testing.T, exportChanged bool) (*cobra.Command, *bytes.Buffer) {212281 t.Helper()213282
A cmd/new_spawn_test.go +165 -0
@@ -0,0 +1,165 @@
1+package cmd2+3+import (4+ "math/rand/v2"5+ "testing"6+7+ "charm.land/bubbles/v2/key"8+ tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/puzzletea/difficulty"10+ "github.com/FelineStateMachine/puzzletea/game"11+ "github.com/FelineStateMachine/puzzletea/puzzle"12+ "github.com/FelineStateMachine/puzzletea/registry"13+)14+15+type testSpawnGamer struct{}16+17+func (testSpawnGamer) GetDebugInfo() string { return "" }18+func (testSpawnGamer) GetFullHelp() [][]key.Binding { return nil }19+func (testSpawnGamer) GetSave() ([]byte, error) { return []byte(`{}`), nil }20+func (testSpawnGamer) IsSolved() bool { return false }21+func (testSpawnGamer) Reset() game.Gamer { return testSpawnGamer{} }22+func (testSpawnGamer) SetTitle(string) game.Gamer { return testSpawnGamer{} }23+func (testSpawnGamer) Init() tea.Cmd { return nil }24+func (testSpawnGamer) View() string { return "" }25+func (testSpawnGamer) Update(tea.Msg) (game.Gamer, tea.Cmd) { return testSpawnGamer{}, nil }26+27+type testLegacySpawner struct {28+ spawned bool29+ seeded bool30+ seededCalled bool31+}32+33+func (s *testLegacySpawner) Spawn() (game.Gamer, error) {34+ s.spawned = true35+ return testSpawnGamer{}, nil36+}37+38+func (s *testLegacySpawner) SpawnSeeded(*rand.Rand) (game.Gamer, error) {39+ s.seeded = true40+ s.seededCalled = true41+ return testSpawnGamer{}, nil42+}43+44+type testEloSpawner struct {45+ testLegacySpawner46+ called bool47+ seed string48+ elo difficulty.Elo49+}50+51+func (s *testEloSpawner) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {52+ s.called = true53+ s.seed = seed54+ s.elo = elo55+ return testSpawnGamer{}, difficulty.Report{56+ TargetElo: elo,57+ ActualElo: elo,58+ Confidence: difficulty.ConfidenceHigh,59+ }, nil60+}61+62+func TestSpawnFromModeUsesPresetEloWhenDifficultyOmitted(t *testing.T) {63+ preset := difficulty.Elo(1200)64+ spawner := &testEloSpawner{}65+ mode := registry.ModeEntry{66+ Definition: puzzle.ModeDef{Title: "Preset", PresetElo: &preset},67+ Spawner: spawner,68+ Elo: spawner,69+ }70+71+ _, report, err := spawnFromMode(mode, "", nil, "generated-name")72+ if err != nil {73+ t.Fatalf("spawnFromMode returned error: %v", err)74+ }75+ if !spawner.called {76+ t.Fatal("expected SpawnElo to be called")77+ }78+ if spawner.seed != "generated-name" {79+ t.Fatalf("Elo seed = %q, want generated save name", spawner.seed)80+ }81+ if spawner.elo != preset || report.TargetElo != preset || report.Confidence == "" {82+ t.Fatalf("Elo/report = (%d, %#v), want preset report", spawner.elo, report)83+ }84+}85+86+func TestSpawnFromVariantUsesDefaultEloWhenDifficultyOmitted(t *testing.T) {87+ defaultElo := difficulty.Elo(1200)88+ spawner := &testEloSpawner{}89+ variant := registry.VariantEntry{90+ Definition: puzzle.VariantDef{Title: "Rule", DefaultElo: defaultElo},91+ Elo: spawner,92+ }93+94+ _, report, err := spawnFromVariant(variant, "", nil, "generated-name")95+ if err != nil {96+ t.Fatalf("spawnFromVariant returned error: %v", err)97+ }98+ if !spawner.called {99+ t.Fatal("expected SpawnElo to be called")100+ }101+ if spawner.seed != "generated-name" {102+ t.Fatalf("Elo seed = %q, want generated save name", spawner.seed)103+ }104+ if spawner.elo != defaultElo || report.TargetElo != defaultElo || report.Confidence == "" {105+ t.Fatalf("Elo/report = (%d, %#v), want default report", spawner.elo, report)106+ }107+}108+109+func TestSpawnFromVariantExplicitDifficultyOverridesDefault(t *testing.T) {110+ explicit := difficulty.Elo(2200)111+ spawner := &testEloSpawner{}112+ variant := registry.VariantEntry{113+ Definition: puzzle.VariantDef{Title: "Rule", DefaultElo: 800},114+ Elo: spawner,115+ }116+117+ _, _, err := spawnFromVariant(variant, "user-seed", &explicit, "generated-name")118+ if err != nil {119+ t.Fatalf("spawnFromVariant returned error: %v", err)120+ }121+ if spawner.elo != explicit {122+ t.Fatalf("Elo = %d, want explicit %d", spawner.elo, explicit)123+ }124+ if spawner.seed != "user-seed" {125+ t.Fatalf("seed = %q, want explicit seed", spawner.seed)126+ }127+}128+129+func TestSpawnFromModeExplicitDifficultyOverridesPreset(t *testing.T) {130+ preset := difficulty.Elo(800)131+ explicit := difficulty.Elo(2200)132+ spawner := &testEloSpawner{}133+ mode := registry.ModeEntry{134+ Definition: puzzle.ModeDef{Title: "Preset", PresetElo: &preset},135+ Spawner: spawner,136+ Elo: spawner,137+ }138+139+ _, _, err := spawnFromMode(mode, "user-seed", &explicit, "generated-name")140+ if err != nil {141+ t.Fatalf("spawnFromMode returned error: %v", err)142+ }143+ if spawner.elo != explicit {144+ t.Fatalf("Elo = %d, want explicit %d", spawner.elo, explicit)145+ }146+ if spawner.seed != "user-seed" {147+ t.Fatalf("seed = %q, want explicit seed", spawner.seed)148+ }149+}150+151+func TestSpawnFromModeFallsBackWithoutElo(t *testing.T) {152+ spawner := &testLegacySpawner{}153+ mode := registry.ModeEntry{154+ Definition: puzzle.ModeDef{Title: "Legacy"},155+ Spawner: spawner,156+ Seeded: spawner,157+ }158+159+ if _, _, err := spawnFromMode(mode, "", nil, "generated-name"); err != nil {160+ t.Fatalf("random spawnFromMode returned error: %v", err)161+ }162+ if !spawner.spawned {163+ t.Fatal("expected legacy Spawn to be called")164+ }165+}
M cmd/test.go +1 -1
M config/config.go +7 -0
M config/config_test.go +26 -0
M daily/daily_test.go +3 -5
A difficulty/candidates.go +43 -0
A difficulty/difficulty.go +50 -0
A difficulty/difficulty_test.go +55 -0
M export/pack/packexport_test.go +20 -1
M export/pack/render.go +55 -13
@@ -52,6 +52,10 @@ func BuildRenderConfig(options RenderOptions, _ []pdfexport.PackDocument) (pdfex
5252 func AnnotatePuzzlesForPrint(puzzles []pdfexport.Puzzle, definitions []puzzle.Definition) {5353 lookup := buildModeDifficultyLookup(definitions)5454 for i := range puzzles {55+ if puzzles[i].ActualDifficultyElo != nil || puzzles[i].TargetDifficultyElo != nil {56+ continue57+ }58+5559 mode := normalizeDifficultyToken(puzzles[i].ModeSelection)5660 if mode == "" || strings.Contains(mode, "mixed modes") {5761 puzzles[i].DifficultyScore = 0.5
@@ -97,27 +101,65 @@ func parseSheetLayout(raw string) (pdfexport.SheetLayout, error) {
97101 func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 {98102 lookup := make(map[string]map[string]float64, len(definitions))99103 for _, def := range definitions {100- titles := []string{}101- for _, mode := range def.Modes {102- title := strings.TrimSpace(mode.Title)104+ scores := make(map[string]float64)105+ variantTitles := []string{}106+ for _, variant := range def.Variants {107+ title := strings.TrimSpace(variant.Title)103108 if title == "" {104109 continue105110 }106- titles = append(titles, title)111+ variantTitles = append(variantTitles, title)107112 }108- if len(titles) == 0 {113+ addModeOrderScores(scores, variantTitles)114+ addLegacyModeOrderScores(scores, def)115+ if len(scores) == 0 {109116 continue110117 }118+ lookup[normalizeDifficultyToken(def.Name)] = scores119+ }120+ return lookup121+}111122 112- scores := make(map[string]float64, len(titles))113- if len(titles) == 1 {114- scores[normalizeDifficultyToken(titles[0])] = 0.5115- } else {116- for i, title := range titles {117- scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1)123+func addLegacyModeOrderScores(scores map[string]float64, def puzzle.Definition) {124+ if len(def.LegacyModes) == 0 {125+ titles := make([]string, 0, len(def.Modes))126+ for _, mode := range def.Modes {127+ title := strings.TrimSpace(mode.Title)128+ if title != "" {129+ titles = append(titles, title)118130 }119131 }120- lookup[normalizeDifficultyToken(def.Name)] = scores132+ addModeOrderScores(scores, titles)133+ return134+ }135+136+ modeIndex := make(map[puzzle.ModeID]int, len(def.Modes))137+ for i, mode := range def.Modes {138+ modeIndex[mode.ID] = i139+ }140+ for _, alias := range def.LegacyModes {141+ title := strings.TrimSpace(alias.Title)142+ if title == "" {143+ continue144+ }145+ i, ok := modeIndex[alias.ID]146+ if !ok || len(def.Modes) <= 1 {147+ scores[normalizeDifficultyToken(title)] = 0.5148+ continue149+ }150+ scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(def.Modes)-1)151+ }152+}153+154+func addModeOrderScores(scores map[string]float64, titles []string) {155+ switch len(titles) {156+ case 0:157+ return158+ case 1:159+ scores[normalizeDifficultyToken(titles[0])] = 0.5160+ default:161+ for i, title := range titles {162+ scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1)163+ }121164 }122- return lookup123165 }
A export/pack/render_test.go +58 -0
@@ -0,0 +1,58 @@
1+package packexport2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/export/pdf"7+ "github.com/FelineStateMachine/puzzletea/puzzle"8+)9+10+func TestAnnotatePuzzlesForPrintUsesLegacyModeOrder(t *testing.T) {11+ definitions := []puzzle.Definition{{12+ Name: "Sudoku",13+ Variants: []puzzle.VariantDef{14+ puzzle.NewVariantDef(puzzle.VariantSpec{Title: "Sudoku", DefaultElo: 1200}),15+ },16+ LegacyModes: []puzzle.LegacyModeAlias{17+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{18+ Title: "Easy",19+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),20+ PresetElo: 600,21+ XPWeight: 1,22+ }),23+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{24+ Title: "Medium",25+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),26+ PresetElo: 1200,27+ XPWeight: 3,28+ }),29+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{30+ Title: "Hard",31+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),32+ PresetElo: 1800,33+ XPWeight: 7,34+ }),35+ },36+ Modes: []puzzle.ModeDef{37+ {ID: puzzle.CanonicalModeID("Easy"), Title: "Easy"},38+ {ID: puzzle.CanonicalModeID("Medium"), Title: "Medium"},39+ {ID: puzzle.CanonicalModeID("Hard"), Title: "Hard"},40+ },41+ }}42+ puzzles := []pdfexport.Puzzle{{43+ Category: "Sudoku",44+ ModeSelection: "Hard",45+ }}46+47+ AnnotatePuzzlesForPrint(puzzles, definitions)48+49+ if got, want := puzzles[0].DifficultyScore, 1.0; got != want {50+ t.Fatalf("DifficultyScore = %v, want %v", got, want)51+ }52+ if got, want := puzzles[0].DifficultyConfidence, pdfexport.DifficultyConfidenceHigh; got != want {53+ t.Fatalf("DifficultyConfidence = %q, want %q", got, want)54+ }55+ if got, want := puzzles[0].DifficultySource, "mode-order"; got != want {56+ t.Fatalf("DifficultySource = %q, want %q", got, want)57+ }58+}
M export/pack/runtime.go +26 -7
@@ -10,6 +10,7 @@ import (
1010 "path/filepath"1111 "strings"1212 13+ "github.com/FelineStateMachine/puzzletea/difficulty"1314 "github.com/FelineStateMachine/puzzletea/export/pdf"1415 "github.com/FelineStateMachine/puzzletea/game"1516 "github.com/FelineStateMachine/puzzletea/puzzle"
@@ -22,6 +23,11 @@ type randState struct {
2223 rng *rand.Rand2324 }2425 26+type generatedSaveData struct {27+ SaveData json.RawMessage28+ Report difficulty.Report29+}30+2531 func newRandState(seed string) *randState {2632 return &randState{rng: resolve.RNGFromString(seed)}2733 }
@@ -33,15 +39,25 @@ func (r *randState) RNG() *rand.Rand {
3339 return r.rng3440 }3541 36-func spawnSaveData(ctx context.Context, selection modeSelection, state *randState) (json.RawMessage, error) {42+func spawnSaveData(ctx context.Context, selection modeSelection, state *randState, eloSeed string) (generatedSaveData, error) {3743 var (3844 puzzleGame game.Gamer45+ report difficulty.Report3946 err error4047 )4148 42- if state != nil {49+ if selection.targetElo != nil {50+ if selection.eloSpawner == nil {51+ return generatedSaveData{}, fmt.Errorf("mode does not support Elo difficulty")52+ }53+ if cancellable, ok := selection.eloSpawner.(game.CancellableEloSpawner); ok {54+ puzzleGame, report, err = cancellable.SpawnEloContext(ctx, eloSeed, *selection.targetElo)55+ } else {56+ puzzleGame, report, err = selection.eloSpawner.SpawnElo(eloSeed, *selection.targetElo)57+ }58+ } else if state != nil {4359 if selection.seededSpawner == nil {44- return nil, fmt.Errorf("mode does not support deterministic spawning")60+ return generatedSaveData{}, fmt.Errorf("mode does not support deterministic spawning")4561 }4662 rng := state.RNG()4763 if cancellable, ok := selection.seededSpawner.(game.CancellableSeededSpawner); ok {
@@ -55,18 +71,21 @@ func spawnSaveData(ctx context.Context, selection modeSelection, state *randStat
5571 puzzleGame, err = selection.spawner.Spawn()5672 }5773 if err != nil {58- return nil, err74+ return generatedSaveData{}, err5975 }6076 6177 save, err := puzzleGame.GetSave()6278 if err != nil {63- return nil, fmt.Errorf("serialize puzzle: %w", err)79+ return generatedSaveData{}, fmt.Errorf("serialize puzzle: %w", err)6480 }6581 if !json.Valid(save) {66- return nil, fmt.Errorf("save payload is not valid json")82+ return generatedSaveData{}, fmt.Errorf("save payload is not valid json")6783 }6884 69- return append(json.RawMessage(nil), save...), nil85+ return generatedSaveData{86+ SaveData: append(json.RawMessage(nil), save...),87+ Report: report,88+ }, nil7089 }7190 7291 func WriteJSONL(path string, records []pdfexport.JSONLRecord) error {
M export/pack/spec.go +97 -31
@@ -10,6 +10,7 @@ import (
1010 "strings"1111 "time"1212 13+ "github.com/FelineStateMachine/puzzletea/difficulty"1314 "github.com/FelineStateMachine/puzzletea/export/builtinprint"1415 "github.com/FelineStateMachine/puzzletea/export/pdf"1516 "github.com/FelineStateMachine/puzzletea/game"
@@ -24,6 +25,8 @@ var Version = "dev"
2425 2526 var nowFn = time.Now2627 28+const PackSpecSchemaV2 = "puzzletea.pack.v2"29+2730 const (2831 DefaultTitle = "PuzzleTea Sampler"2932 DefaultHeader = "A small mixed pack that highlights a few PuzzleTea puzzle styles."
@@ -32,6 +35,7 @@ const (
3235 )3336 3437 type Spec struct {38+ Schema string `json:"schema,omitempty"`3539 Title string `json:"title,omitempty"`3640 Header string `json:"header,omitempty"`3741 Advert string `json:"advert,omitempty"`
@@ -61,6 +65,7 @@ type ModeCatalog struct {
6165 ModeID puzzle.ModeID6266 ModeTitle string6367 Seeded bool68+ PresetElo *int6469 }6570 6671 type modeSelection struct {
@@ -71,6 +76,8 @@ type modeSelection struct {
7176 count int7277 spawner game.Spawner7378 seededSpawner game.SeededSpawner79+ eloSpawner game.EloSpawner80+ targetElo *difficulty.Elo7481 }7582 7683 type runPlan struct {
@@ -84,6 +91,7 @@ func DefaultSpec(cwd string) Spec {
8491 }8592 8693 spec := Spec{94+ Schema: PackSpecSchemaV2,8795 Title: DefaultTitle,8896 Header: DefaultHeader,8997 Advert: DefaultAdvert,
@@ -93,14 +101,14 @@ func DefaultSpec(cwd string) Spec {
93101 Counts: zeroCounts(),94102 }95103 96- setDefaultCount(spec.Counts, "Sudoku", "Easy", 2)97- setDefaultCount(spec.Counts, "Word Search", "Easy 10x10", 2)98- setDefaultCount(spec.Counts, "Hitori", "Easy", 1)99- setDefaultCount(spec.Counts, "Nonogram", "Standard", 1)100- setDefaultCount(spec.Counts, "Nurikabe", "Easy", 1)101- setDefaultCount(spec.Counts, "Takuzu", "Easy", 1)102- setDefaultCount(spec.Counts, "Shikaku", "Easy 7x7", 1)103- setDefaultCount(spec.Counts, "Spell Puzzle", "Easy", 1)104+ setDefaultCount(spec.Counts, "Sudoku", "Sudoku", 2)105+ setDefaultCount(spec.Counts, "Word Search", "Word Search", 2)106+ setDefaultCount(spec.Counts, "Hitori", "Hitori", 1)107+ setDefaultCount(spec.Counts, "Nonogram", "Nonogram", 1)108+ setDefaultCount(spec.Counts, "Nurikabe", "Nurikabe", 1)109+ setDefaultCount(spec.Counts, "Takuzu", "Takuzu", 1)110+ setDefaultCount(spec.Counts, "Shikaku", "Shikaku", 1)111+ setDefaultCount(spec.Counts, "Spell Puzzle", "Spell Puzzle", 1)104112 105113 return spec106114 }
@@ -113,14 +121,16 @@ func ExportCatalog() []GameCatalog {
113121 continue114122 }115123 116- gameModes := make([]ModeCatalog, 0, len(entry.Modes))117- for _, mode := range entry.Modes {124+ gameModes := make([]ModeCatalog, 0, len(entry.Variants))125+ for _, variant := range entry.Variants {126+ elo := int(variant.Definition.DefaultElo)118127 gameModes = append(gameModes, ModeCatalog{119128 GameID: entry.Definition.ID,120129 GameType: entry.Definition.Name,121- ModeID: mode.Definition.ID,122- ModeTitle: mode.Definition.Title,123- Seeded: mode.Seeded != nil,130+ ModeID: puzzle.ModeID(variant.Definition.ID),131+ ModeTitle: variant.Definition.Title,132+ Seeded: variant.Seeded != nil,133+ PresetElo: &elo,124134 })125135 }126136
@@ -271,6 +281,9 @@ func setDefaultCount(counts map[puzzle.GameID]map[puzzle.ModeID]int, gameName, m
271281 func buildRunPlan(spec Spec) (runPlan, error) {272282 spec = normalizeSpec(spec)273283 284+ if spec.Schema != PackSpecSchemaV2 {285+ return runPlan{}, fmt.Errorf("unsupported pack spec schema %q", spec.Schema)286+ }274287 if !strings.EqualFold(filepath.Ext(spec.PDFOutputPath), ".pdf") {275288 return runPlan{}, fmt.Errorf("pdf output path must use a .pdf extension")276289 }
@@ -308,9 +321,13 @@ func buildRunPlan(spec Spec) (runPlan, error) {
308321 continue309322 }310323 311- modeByID := make(map[puzzle.ModeID]registry.ModeEntry, len(entry.Modes))312- for _, mode := range entry.Modes {313- modeByID[mode.Definition.ID] = mode324+ variantByID := make(map[puzzle.ModeID]registry.VariantEntry, len(entry.Variants))325+ for _, variant := range entry.Variants {326+ variantByID[puzzle.ModeID(variant.Definition.ID)] = variant327+ }328+ legacyByID := make(map[puzzle.ModeID]puzzle.LegacyModeAlias, len(entry.LegacyModes))329+ for _, alias := range entry.LegacyModes {330+ legacyByID[alias.ID] = alias314331 }315332 316333 for modeID, count := range modes {
@@ -321,23 +338,25 @@ func buildRunPlan(spec Spec) (runPlan, error) {
321338 continue322339 }323340 324- mode, ok := modeByID[modeID]341+ variant, targetElo, ok := exportVariantForModeID(entry, variantByID, legacyByID, modeID)325342 if !ok {326- return runPlan{}, fmt.Errorf("%s / %s is not a known export mode", entry.Definition.Name, modeID)343+ return runPlan{}, fmt.Errorf("%s / %s is not a known export variant", entry.Definition.Name, modeID)327344 }328- if strings.TrimSpace(spec.Seed) != "" && mode.Seeded == nil {329- return runPlan{}, fmt.Errorf("%s / %s does not support seeded export", entry.Definition.Name, mode.Definition.Title)345+ if strings.TrimSpace(spec.Seed) != "" && variant.Seeded == nil {346+ return runPlan{}, fmt.Errorf("%s / %s does not support seeded export", entry.Definition.Name, variant.Definition.Title)330347 }331348 332349 total += count333350 selections = append(selections, modeSelection{334351 gameID: entry.Definition.ID,335352 gameType: entry.Definition.Name,336- modeID: mode.Definition.ID,337- modeTitle: mode.Definition.Title,353+ modeID: puzzle.ModeID(variant.Definition.ID),354+ modeTitle: variant.Definition.Title,338355 count: count,339- spawner: mode.Spawner,340- seededSpawner: mode.Seeded,356+ spawner: variant.Seeded,357+ seededSpawner: variant.Seeded,358+ eloSpawner: variant.Elo,359+ targetElo: &targetElo,341360 })342361 }343362 }
@@ -359,7 +378,29 @@ func buildRunPlan(spec Spec) (runPlan, error) {
359378 }, nil360379 }361380 381+func exportVariantForModeID(382+ entry registry.Entry,383+ variantByID map[puzzle.ModeID]registry.VariantEntry,384+ legacyByID map[puzzle.ModeID]puzzle.LegacyModeAlias,385+ modeID puzzle.ModeID,386+) (registry.VariantEntry, difficulty.Elo, bool) {387+ if variant, ok := variantByID[modeID]; ok {388+ return variant, variant.Definition.DefaultElo, true389+ }390+ alias, ok := legacyByID[modeID]391+ if !ok {392+ return registry.VariantEntry{}, 0, false393+ }394+ for _, variant := range entry.Variants {395+ if variant.Definition.ID == alias.TargetVariantID {396+ return variant, alias.PresetElo, true397+ }398+ }399+ return registry.VariantEntry{}, 0, false400+}401+362402 func normalizeSpec(spec Spec) Spec {403+ spec.Schema = strings.TrimSpace(spec.Schema)363404 spec.Title = strings.TrimSpace(spec.Title)364405 spec.Header = strings.TrimSpace(spec.Header)365406 spec.Advert = strings.TrimSpace(spec.Advert)
@@ -367,6 +408,9 @@ func normalizeSpec(spec Spec) Spec {
367408 spec.PDFOutputPath = strings.TrimSpace(spec.PDFOutputPath)368409 spec.JSONLOutputPath = strings.TrimSpace(spec.JSONLOutputPath)369410 411+ if spec.Schema == "" {412+ spec.Schema = PackSpecSchemaV2413+ }370414 if spec.Volume == 0 {371415 spec.Volume = 1372416 }
@@ -405,19 +449,22 @@ func generateRecords(
405449 }406450 407451 records := make([]pdfexport.JSONLRecord, 0, totalCount)452+ recordIndex := 0408453 for _, selection := range selections {409454 for i := 0; i < selection.count; i++ {410455 if err := ctx.Err(); err != nil {411456 return nil, err412457 }458+ recordIndex++413459 414- saveData, err := spawnSaveData(ctx, selection, rng)460+ eloSeed := scopedPackEloSeed(seed, generatedAt, selection.gameType, selection.modeTitle, recordIndex)461+ generated, err := spawnSaveData(ctx, selection, rng, eloSeed)415462 if err != nil {416463 return nil, fmt.Errorf("generate %s / %s puzzle %d: %w", selection.gameType, selection.modeTitle, i+1, err)417464 }418465 419466 records = append(records, pdfexport.JSONLRecord{420- Schema: pdfexport.ExportSchemaV1,467+ Schema: pdfexport.ExportSchemaV2,421468 Pack: pdfexport.JSONLPackMeta{422469 Generated: generatedAt.Format(time.RFC3339),423470 Version: Version,
@@ -427,11 +474,14 @@ func generateRecords(
427474 Seed: seed,428475 },429476 Puzzle: pdfexport.JSONLPuzzle{430- Index: len(records) + 1,431- Name: namegen.GenerateSeeded(nameRNG),432- Game: selection.gameType,433- Mode: selection.modeTitle,434- Save: saveData,477+ Index: len(records) + 1,478+ Name: namegen.GenerateSeeded(nameRNG),479+ Game: selection.gameType,480+ Mode: selection.modeTitle,481+ TargetDifficultyElo: intPtrFromReportElo(generated.Report.TargetElo, generated.Report.Confidence),482+ ActualDifficultyElo: intPtrFromReportElo(generated.Report.ActualElo, generated.Report.Confidence),483+ DifficultyConfidence: string(generated.Report.Confidence),484+ Save: generated.SaveData,435485 },436486 })437487 }
@@ -439,3 +489,19 @@ func generateRecords(
439489 440490 return records, nil441491 }492+493+func intPtrFromReportElo(elo difficulty.Elo, confidence difficulty.Confidence) *int {494+ if confidence == "" {495+ return nil496+ }497+ v := int(elo)498+ return &v499+}500+501+func scopedPackEloSeed(seed string, generatedAt time.Time, gameType, mode string, index int) string {502+ base := strings.TrimSpace(seed)503+ if base == "" {504+ base = generatedAt.Format(time.RFC3339Nano)505+ }506+ return fmt.Sprintf("packexport:%s:%s:%s:%d", base, gameType, mode, index)507+}
M export/pdf/jsonl.go +71 -14
@@ -9,9 +9,14 @@ import (
99 "path/filepath"1010 "slices"1111 "strings"12+13+ "github.com/FelineStateMachine/puzzletea/difficulty"1214 )1315 14-const ExportSchemaV1 = "puzzletea.export.v1"16+const (17+ ExportSchemaV1 = "puzzletea.export.v1"18+ ExportSchemaV2 = "puzzletea.export.v2"19+)1520 1621 type JSONLRecord struct {1722 Schema string `json:"schema"`
@@ -29,11 +34,14 @@ type JSONLPackMeta struct {
2934 }3035 3136 type JSONLPuzzle struct {32- Index int `json:"index"`33- Name string `json:"name"`34- Game string `json:"game"`35- Mode string `json:"mode"`36- Save json.RawMessage `json:"save"`37+ Index int `json:"index"`38+ Name string `json:"name"`39+ Game string `json:"game"`40+ Mode string `json:"mode"`41+ TargetDifficultyElo *int `json:"target_difficulty_elo,omitempty"`42+ ActualDifficultyElo *int `json:"actual_difficulty_elo,omitempty"`43+ DifficultyConfidence string `json:"difficulty_confidence,omitempty"`44+ Save json.RawMessage `json:"save"`3745 }3846 3947 func ParseJSONLFiles(paths []string) ([]PackDocument, error) {
@@ -103,7 +111,7 @@ func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error
103111 if err := json.Unmarshal([]byte(line), &record); err != nil {104112 return PackDocument{}, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)105113 }106- if record.Schema != ExportSchemaV1 {114+ if !IsSupportedExportSchema(record.Schema) {107115 return PackDocument{}, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)108116 }109117
@@ -117,14 +125,17 @@ func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error
117125 }118126 119127 p := Puzzle{120- SourcePath: path,121- SourceFileName: filepath.Base(path),122- Category: category,123- ModeSelection: mode,124- Name: record.Puzzle.Name,125- Index: record.Puzzle.Index,126- SaveData: append([]byte(nil), record.Puzzle.Save...),128+ SourcePath: path,129+ SourceFileName: filepath.Base(path),130+ Category: category,131+ ModeSelection: mode,132+ Name: record.Puzzle.Name,133+ Index: record.Puzzle.Index,134+ SaveData: append([]byte(nil), record.Puzzle.Save...),135+ TargetDifficultyElo: cloneInt(record.Puzzle.TargetDifficultyElo),136+ ActualDifficultyElo: cloneInt(record.Puzzle.ActualDifficultyElo),127137 }138+ annotateJSONLDifficulty(&p, record.Puzzle)128139 129140 adapter, ok := LookupPrintAdapter(category)130141 if !ok {
@@ -178,3 +189,49 @@ func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error
178189 doc.Puzzles = puzzles179190 return doc, nil180191 }192+193+func IsSupportedExportSchema(schema string) bool {194+ switch schema {195+ case ExportSchemaV1, ExportSchemaV2:196+ return true197+ default:198+ return false199+ }200+}201+202+func annotateJSONLDifficulty(p *Puzzle, puzzle JSONLPuzzle) {203+ if puzzle.ActualDifficultyElo != nil {204+ elo := difficulty.Elo(*puzzle.ActualDifficultyElo)205+ p.DifficultyScore = difficulty.Score01(elo)206+ p.DifficultyConfidence = parseDifficultyConfidence(puzzle.DifficultyConfidence)207+ p.DifficultySource = "actual-elo"208+ return209+ }210+ if puzzle.TargetDifficultyElo != nil {211+ elo := difficulty.Elo(*puzzle.TargetDifficultyElo)212+ p.DifficultyScore = difficulty.Score01(elo)213+ p.DifficultyConfidence = parseDifficultyConfidence(puzzle.DifficultyConfidence)214+ p.DifficultySource = "target-elo"215+ }216+}217+218+func parseDifficultyConfidence(raw string) DifficultyConfidence {219+ switch difficulty.Confidence(strings.ToLower(strings.TrimSpace(raw))) {220+ case difficulty.ConfidenceLow:221+ return DifficultyConfidenceLow222+ case difficulty.ConfidenceMedium:223+ return DifficultyConfidenceMedium224+ case difficulty.ConfidenceHigh:225+ return DifficultyConfidenceHigh226+ default:227+ return DifficultyConfidenceMedium228+ }229+}230+231+func cloneInt(value *int) *int {232+ if value == nil {233+ return nil234+ }235+ v := *value236+ return &v237+}
A export/pdf/jsonl_difficulty_test.go +92 -0
@@ -0,0 +1,92 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "path/filepath"6+ "testing"7+)8+9+func TestParseJSONLFilePrefersDifficultyElo(t *testing.T) {10+ path := filepath.Join(t.TempDir(), "pack.jsonl")11+ targetElo := 120012+ actualElo := 180013+ record := JSONLRecord{14+ Schema: ExportSchemaV1,15+ Pack: JSONLPackMeta{16+ Generated: "2026-02-22T10:00:00Z",17+ Version: "v-test",18+ Category: "Nonogram",19+ ModeSelection: "Mini",20+ Count: 1,21+ },22+ Puzzle: JSONLPuzzle{23+ Index: 1,24+ Name: "ember-newt",25+ Game: "Nonogram",26+ Mode: "Mini",27+ TargetDifficultyElo: &targetElo,28+ ActualDifficultyElo: &actualElo,29+ DifficultyConfidence: "high",30+ Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`),31+ },32+ }33+ writeSingleJSONLRecord(t, path, record)34+35+ doc, err := ParseJSONLFile(path)36+ if err != nil {37+ t.Fatal(err)38+ }39+ puzzle := doc.Puzzles[0]40+ if puzzle.TargetDifficultyElo == nil || *puzzle.TargetDifficultyElo != targetElo {41+ t.Fatalf("TargetDifficultyElo = %v, want %d", puzzle.TargetDifficultyElo, targetElo)42+ }43+ if puzzle.ActualDifficultyElo == nil || *puzzle.ActualDifficultyElo != actualElo {44+ t.Fatalf("ActualDifficultyElo = %v, want %d", puzzle.ActualDifficultyElo, actualElo)45+ }46+ if got, want := puzzle.DifficultyScore, 0.6; got != want {47+ t.Fatalf("DifficultyScore = %v, want %v", got, want)48+ }49+ if got, want := puzzle.DifficultyConfidence, DifficultyConfidenceHigh; got != want {50+ t.Fatalf("DifficultyConfidence = %q, want %q", got, want)51+ }52+ if got, want := puzzle.DifficultySource, "actual-elo"; got != want {53+ t.Fatalf("DifficultySource = %q, want %q", got, want)54+ }55+}56+57+func TestParseJSONLFileFallsBackToTargetDifficultyElo(t *testing.T) {58+ path := filepath.Join(t.TempDir(), "pack.jsonl")59+ targetElo := 150060+ record := JSONLRecord{61+ Schema: ExportSchemaV1,62+ Pack: JSONLPackMeta{63+ Generated: "2026-02-22T10:00:00Z",64+ Version: "v-test",65+ Category: "Nonogram",66+ ModeSelection: "Mini",67+ Count: 1,68+ },69+ Puzzle: JSONLPuzzle{70+ Index: 1,71+ Name: "ember-newt",72+ Game: "Nonogram",73+ Mode: "Mini",74+ TargetDifficultyElo: &targetElo,75+ DifficultyConfidence: "medium",76+ Save: json.RawMessage(`{"width":2,"height":2,"row-hints":[[1],[1]],"col-hints":[[1],[1]],"state":" \n "}`),77+ },78+ }79+ writeSingleJSONLRecord(t, path, record)80+81+ doc, err := ParseJSONLFile(path)82+ if err != nil {83+ t.Fatal(err)84+ }85+ puzzle := doc.Puzzles[0]86+ if got, want := puzzle.DifficultyScore, 0.5; got != want {87+ t.Fatalf("DifficultyScore = %v, want %v", got, want)88+ }89+ if got, want := puzzle.DifficultySource, "target-elo"; got != want {90+ t.Fatalf("DifficultySource = %q, want %q", got, want)91+ }92+}
M export/pdf/types.go +3 -0
M game/gamer.go +15 -2
M gameentry/gameentry.go +218 -17
@@ -3,8 +3,12 @@
33 package gameentry44 55 import (6+ "context"67 "fmt"8+ "math"9+ "math/rand/v2"710 11+ "github.com/FelineStateMachine/puzzletea/difficulty"812 "github.com/FelineStateMachine/puzzletea/export/pdf"913 "github.com/FelineStateMachine/puzzletea/game"1014 "github.com/FelineStateMachine/puzzletea/puzzle"
@@ -14,14 +18,23 @@ type ModeEntry struct {
1418 Definition puzzle.ModeDef1519 Spawner game.Spawner1620 Seeded game.SeededSpawner21+ Elo game.EloSpawner22+}23+24+type VariantEntry struct {25+ Definition puzzle.VariantDef26+ Elo game.EloSpawner27+ Seeded game.SeededSpawner1728 }1829 1930 type Entry struct {20- Definition puzzle.Definition21- Help string22- Import func([]byte) (game.Gamer, error)23- Modes []ModeEntry24- Print pdfexport.PrintAdapter31+ Definition puzzle.Definition32+ Help string33+ Import func([]byte) (game.Gamer, error)34+ Variants []VariantEntry35+ LegacyModes []puzzle.LegacyModeAlias36+ Modes []ModeEntry37+ Print pdfexport.PrintAdapter2538 }2639 2740 type EntrySpec struct {
@@ -47,12 +60,55 @@ func BuildModeDefs(modes []game.Mode) []puzzle.ModeDef {
4760 return defs4861 }4962 63+func BuildDefaultVariantDef(title, description string, defaultElo difficulty.Elo) puzzle.VariantDef {64+ return puzzle.NewVariantDef(puzzle.VariantSpec{65+ Title: title,66+ Description: description,67+ DefaultElo: defaultElo,68+ })69+}70+71+func BuildLegacyModeAliases(modes []puzzle.ModeDef, target puzzle.VariantID) []puzzle.LegacyModeAlias {72+ aliases := make([]puzzle.LegacyModeAlias, 0, len(modes))73+ for i, mode := range modes {74+ if mode.PresetElo == nil {75+ continue76+ }77+ aliases = append(aliases, puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{78+ ID: mode.ID,79+ Title: mode.Title,80+ Description: mode.Description,81+ TargetVariantID: target,82+ PresetElo: *mode.PresetElo,83+ XPWeight: legacyXPWeight(i, len(modes)),84+ }))85+ }86+ return aliases87+}88+89+func legacyXPWeight(index, count int) int {90+ if count <= 0 {91+ return 192+ }93+ return max(1, int(math.Round(float64(index)/float64(count)*10)))94+}95+5096 func NewEntry(spec EntrySpec) Entry {51- if len(spec.Definition.Modes) != len(spec.Modes) {97+ definition := spec.Definition98+ if len(definition.Variants) == 0 && hasEloMode(definition.Modes) {99+ definition.Variants = []puzzle.VariantDef{100+ BuildDefaultVariantDef(definition.Name, definition.Description, defaultEloForModes(definition.Modes)),101+ }102+ }103+ if len(definition.LegacyModes) == 0 && len(definition.Variants) == 1 {104+ definition.LegacyModes = BuildLegacyModeAliases(definition.Modes, definition.Variants[0].ID)105+ }106+107+ if len(definition.Modes) != len(spec.Modes) {52108 panic(fmt.Sprintf(53109 "gameentry: definition %q has %d mode definitions but %d runtime modes",54- spec.Definition.Name,55- len(spec.Definition.Modes),110+ definition.Name,111+ len(definition.Modes),56112 len(spec.Modes),57113 ))58114 }
@@ -63,16 +119,16 @@ func NewEntry(spec EntrySpec) Entry {
63119 if !ok {64120 panic(fmt.Sprintf(65121 "gameentry: definition %q mode %q does not implement game.Spawner",66- spec.Definition.Name,122+ definition.Name,67123 mode.Title(),68124 ))69125 }70126 71- modeDef := spec.Definition.Modes[i]127+ modeDef := definition.Modes[i]72128 if modeDef.Title != mode.Title() || modeDef.Description != mode.Description() {73129 panic(fmt.Sprintf(74130 "gameentry: definition %q mode %d metadata does not match runtime mode %q",75- spec.Definition.Name,131+ definition.Name,76132 i,77133 mode.Title(),78134 ))
@@ -85,21 +141,166 @@ func NewEntry(spec EntrySpec) Entry {
85141 if seeded, ok := mode.(game.SeededSpawner); ok {86142 entry.Seeded = seeded87143 }144+ if elo, ok := mode.(game.EloSpawner); ok {145+ entry.Elo = elo146+ }88147 if entry.Definition.Seeded != (entry.Seeded != nil) {89148 panic(fmt.Sprintf(90149 "gameentry: definition %q mode %q seeded flag does not match runtime mode",91- spec.Definition.Name,150+ definition.Name,92151 modeDef.Title,93152 ))94153 }154+ if entry.Definition.PresetElo != nil {155+ if err := difficulty.ValidateElo(*entry.Definition.PresetElo); err != nil {156+ panic(fmt.Sprintf(157+ "gameentry: definition %q mode %q preset Elo is invalid: %v",158+ definition.Name,159+ modeDef.Title,160+ err,161+ ))162+ }163+ if entry.Elo == nil {164+ panic(fmt.Sprintf(165+ "gameentry: definition %q mode %q has preset Elo but runtime mode does not implement game.EloSpawner",166+ definition.Name,167+ modeDef.Title,168+ ))169+ }170+ }95171 entries = append(entries, entry)96172 }97173 174+ variants := buildVariantEntries(definition, entries)175+98176 return Entry{99- Definition: spec.Definition,100- Help: spec.Help,101- Import: spec.Import,102- Modes: entries,103- Print: spec.Print,177+ Definition: definition,178+ Help: spec.Help,179+ Import: spec.Import,180+ Variants: variants,181+ LegacyModes: append([]puzzle.LegacyModeAlias(nil), definition.LegacyModes...),182+ Modes: entries,183+ Print: spec.Print,184+ }185+}186+187+func hasEloMode(modes []puzzle.ModeDef) bool {188+ for _, mode := range modes {189+ if mode.PresetElo != nil {190+ return true191+ }192+ }193+ return false194+}195+196+func defaultEloForModes(modes []puzzle.ModeDef) difficulty.Elo {197+ presets := make([]difficulty.Elo, 0, len(modes))198+ for _, mode := range modes {199+ if mode.PresetElo != nil {200+ presets = append(presets, *mode.PresetElo)201+ }202+ }203+ if len(presets) == 0 {204+ return 1200205+ }206+ return presets[len(presets)/2]207+}208+209+func buildVariantEntries(def puzzle.Definition, modes []ModeEntry) []VariantEntry {210+ if len(def.Variants) == 0 {211+ return nil212+ }213+ result := make([]VariantEntry, 0, len(def.Variants))214+ for _, variant := range def.Variants {215+ spawners := make([]ModeEntry, 0, len(modes))216+ for _, mode := range modes {217+ if mode.Elo != nil {218+ spawners = append(spawners, mode)219+ }220+ }221+ if len(spawners) == 0 {222+ panic(fmt.Sprintf(223+ "gameentry: definition %q variant %q has no Elo-capable legacy modes",224+ def.Name,225+ variant.Title,226+ ))227+ }228+ elo := variantEloSpawner{229+ defaultElo: variant.DefaultElo,230+ modes: spawners,231+ }232+ result = append(result, VariantEntry{233+ Definition: variant,234+ Elo: elo,235+ Seeded: variantSeededSpawner{236+ elo: elo,237+ defaultElo: variant.DefaultElo,238+ },239+ })240+ }241+ return result242+}243+244+type variantEloSpawner struct {245+ defaultElo difficulty.Elo246+ modes []ModeEntry247+}248+249+func (s variantEloSpawner) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {250+ return s.SpawnEloContext(context.Background(), seed, elo)251+}252+253+func (s variantEloSpawner) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {254+ if err := difficulty.ValidateElo(elo); err != nil {255+ return nil, difficulty.Report{}, err256+ }257+ mode := s.modeForElo(elo)258+ if cancellable, ok := mode.Elo.(game.CancellableEloSpawner); ok {259+ return cancellable.SpawnEloContext(ctx, seed, elo)260+ }261+ return mode.Elo.SpawnElo(seed, elo)262+}263+264+func (s variantEloSpawner) modeForElo(elo difficulty.Elo) ModeEntry {265+ if len(s.modes) == 1 {266+ return s.modes[0]267+ }268+ band := int(elo) * len(s.modes) / (int(difficulty.SoftCapElo) + 1)269+ if band < 0 {270+ band = 0271+ }272+ if band >= len(s.modes) {273+ band = len(s.modes) - 1274+ }275+ return s.modes[band]276+}277+278+type variantSeededSpawner struct {279+ elo game.EloSpawner280+ defaultElo difficulty.Elo281+}282+283+func (s variantSeededSpawner) Spawn() (game.Gamer, error) {284+ return s.SpawnSeeded(rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))285+}286+287+func (s variantSeededSpawner) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {288+ return s.SpawnSeededContext(context.Background(), rng)289+}290+291+func (s variantSeededSpawner) SpawnSeededContext(ctx context.Context, rng *rand.Rand) (game.Gamer, error) {292+ if rng == nil {293+ return nil, fmt.Errorf("nil RNG")294+ }295+ seed := fmt.Sprintf("variant-seeded:%016x:%016x", rng.Uint64(), rng.Uint64())296+ var (297+ g game.Gamer298+ err error299+ )300+ if cancellable, ok := s.elo.(game.CancellableEloSpawner); ok {301+ g, _, err = cancellable.SpawnEloContext(ctx, seed, s.defaultElo)302+ } else {303+ g, _, err = s.elo.SpawnElo(seed, s.defaultElo)104304 }305+ return g, err105306 }
M gameentry/gameentry_test.go +86 -0
@@ -1,10 +1,12 @@
11 package gameentry22 33 import (4+ "context"45 "errors"56 "math/rand/v2"67 "testing"78 9+ "github.com/FelineStateMachine/puzzletea/difficulty"810 "github.com/FelineStateMachine/puzzletea/game"911 "github.com/FelineStateMachine/puzzletea/puzzle"1012 )
@@ -25,6 +27,23 @@ func (m seededStubMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
2527 return nil, errors.New("not implemented")2628 }2729 30+type cancellableEloStubMode struct {31+ stubMode32+ calledWithCanceledContext bool33+}34+35+func (m *cancellableEloStubMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {36+ return nil, difficulty.Report{}, errors.New("non-context spawn called")37+}38+39+func (m *cancellableEloStubMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {40+ if err := ctx.Err(); err != nil {41+ m.calledWithCanceledContext = true42+ return nil, difficulty.Report{}, err43+ }44+ return nil, difficulty.Report{}, nil45+}46+2847 func TestBuildModeDefsMarksSeededModes(t *testing.T) {2948 modes := []game.Mode{3049 stubMode{BaseMode: game.NewBaseMode("Beginner", "Easy board")},
@@ -73,3 +92,70 @@ func TestNewEntryKeepsMetadataAndRuntimeModesAligned(t *testing.T) {
7392 t.Fatal("Print = non-nil, want nil")7493 }7594 }95+96+func TestVariantEloSpawnerPreservesCancellation(t *testing.T) {97+ preset := difficulty.Elo(1200)98+ mode := &cancellableEloStubMode{99+ stubMode: stubMode{BaseMode: game.NewBaseMode("Hard", "Hard board")},100+ }101+ entry := NewEntry(EntrySpec{102+ Definition: puzzle.NewDefinition(puzzle.DefinitionSpec{103+ Name: "Test",104+ Modes: []puzzle.ModeDef{puzzle.NewModeDef(puzzle.ModeSpec{105+ Title: mode.Title(),106+ Description: mode.Description(),107+ PresetElo: &preset,108+ })},109+ }),110+ Modes: []game.Mode{mode},111+ })112+ if len(entry.Variants) != 1 {113+ t.Fatalf("len(Variants) = %d, want 1", len(entry.Variants))114+ }115+ spawner, ok := entry.Variants[0].Elo.(game.CancellableEloSpawner)116+ if !ok {117+ t.Fatal("variant Elo spawner does not implement game.CancellableEloSpawner")118+ }119+120+ ctx, cancel := context.WithCancel(t.Context())121+ cancel()122+ _, _, err := spawner.SpawnEloContext(ctx, "seed", preset)123+ if !errors.Is(err, context.Canceled) {124+ t.Fatalf("SpawnEloContext error = %v, want context.Canceled", err)125+ }126+ if !mode.calledWithCanceledContext {127+ t.Fatal("underlying mode did not receive the canceled context")128+ }129+}130+131+func TestVariantSeededSpawnerPreservesCancellation(t *testing.T) {132+ preset := difficulty.Elo(1200)133+ mode := &cancellableEloStubMode{134+ stubMode: stubMode{BaseMode: game.NewBaseMode("Hard", "Hard board")},135+ }136+ entry := NewEntry(EntrySpec{137+ Definition: puzzle.NewDefinition(puzzle.DefinitionSpec{138+ Name: "Test",139+ Modes: []puzzle.ModeDef{puzzle.NewModeDef(puzzle.ModeSpec{140+ Title: mode.Title(),141+ Description: mode.Description(),142+ PresetElo: &preset,143+ })},144+ }),145+ Modes: []game.Mode{mode},146+ })147+ spawner, ok := entry.Variants[0].Seeded.(game.CancellableSeededSpawner)148+ if !ok {149+ t.Fatal("variant seeded spawner does not implement game.CancellableSeededSpawner")150+ }151+152+ ctx, cancel := context.WithCancel(t.Context())153+ cancel()154+ _, err := spawner.SpawnSeededContext(ctx, rand.New(rand.NewPCG(1, 2)))155+ if !errors.Is(err, context.Canceled) {156+ t.Fatalf("SpawnSeededContext error = %v, want context.Canceled", err)157+ }158+ if !mode.calledWithCanceledContext {159+ t.Fatal("underlying mode did not receive the canceled context")160+ }161+}
M games/fillomino/README.md +5 -0
A games/fillomino/elo.go +154 -0
@@ -0,0 +1,154 @@
1+package fillomino2+3+import (4+ "crypto/sha256"5+ "encoding/binary"6+ "errors"7+ "math"8+ "math/rand/v2"9+ "strconv"10+11+ "github.com/FelineStateMachine/puzzletea/difficulty"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+var _ game.EloSpawner = Mode{}16+17+func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18+ if err := difficulty.ValidateElo(elo); err != nil {19+ return nil, difficulty.Report{}, err20+ }21+22+ mode := fillominoModeForElo(m, elo)23+ var bestPuzzle Puzzle24+ var bestReport difficulty.Report25+ haveBest := false26+ var lastErr error27+ for candidate := range fillominoEloCandidateCount(elo) {28+ puzzle, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, fillominoEloRNG(fillominoCandidateSeed(seed, candidate), elo))29+ if err != nil {30+ lastErr = err31+ continue32+ }33+ report := fillominoDifficultyReport(elo, mode, puzzle)34+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {35+ bestPuzzle = puzzle36+ bestReport = report37+ haveBest = true38+ }39+ }40+ if !haveBest {41+ if lastErr == nil {42+ lastErr = errors.New("unable to generate Elo fillomino")43+ }44+ return nil, difficulty.Report{}, lastErr45+ }46+ g, err := New(mode, bestPuzzle)47+ if err != nil {48+ return nil, difficulty.Report{}, err49+ }50+ return g, bestReport, nil51+}52+53+func fillominoModeForElo(base Mode, elo difficulty.Elo) Mode {54+ score := difficulty.Score01(elo)55+ maxRegion := 5 + int(math.Round(score*3))56+ givenRatio := 0.70 - score*0.1457+ mode := base58+ mode.MaxRegion = min(maxRegion, max(1, mode.Size*mode.Size))59+ mode.GivenRatio = givenRatio60+ return mode61+}62+63+func fillominoEloCandidateCount(elo difficulty.Elo) int {64+ if elo >= 2400 {65+ return 166+ }67+ return difficulty.CandidateCount(elo)68+}69+70+func fillominoEloRNG(seed string, elo difficulty.Elo) *rand.Rand {71+ sum := sha256.Sum256([]byte("fillomino\x00" + seed + "\x00" + strconv.Itoa(int(elo))))72+ return rand.New(rand.NewPCG(73+ binary.LittleEndian.Uint64(sum[0:8]),74+ binary.LittleEndian.Uint64(sum[8:16]),75+ ))76+}77+78+func fillominoCandidateSeed(seed string, candidate int) string {79+ if candidate == 0 {80+ return seed81+ }82+ return seed + "\x00candidate:" + strconv.Itoa(candidate)83+}84+85+func fillominoDifficultyReport(target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {86+ solutions := countSolutions(puzzle.Givens, mode.MaxRegion, 2)87+ metrics := fillominoDifficultyMetrics(mode, puzzle, solutions)88+ confidence := difficulty.ConfidenceHigh89+ if solutions != 1 {90+ confidence = difficulty.ConfidenceLow91+ }92+ return difficulty.Report{93+ TargetElo: target,94+ ActualElo: fillominoActualElo(metrics),95+ Confidence: confidence,96+ Metrics: metrics,97+ }98+}99+100+func fillominoDifficultyMetrics(mode Mode, puzzle Puzzle, solutions int) difficulty.Metrics {101+ cells := puzzle.Width * puzzle.Height102+ givens := 0103+ maxRegionValue := 0104+ for _, row := range puzzle.Givens {105+ for _, value := range row {106+ if value == 0 {107+ continue108+ }109+ givens++110+ if value > maxRegionValue {111+ maxRegionValue = value112+ }113+ }114+ }115+ return difficulty.Metrics{116+ "width": float64(puzzle.Width),117+ "height": float64(puzzle.Height),118+ "cells": float64(cells),119+ "given_count": float64(givens),120+ "unknown_count": float64(cells - givens),121+ "given_ratio": fillominoRatio(givens, cells),122+ "target_ratio": mode.GivenRatio,123+ "max_region": float64(mode.MaxRegion),124+ "max_given_region": float64(maxRegionValue),125+ "solution_count": float64(solutions),126+ }127+}128+129+func fillominoActualElo(metrics difficulty.Metrics) difficulty.Elo {130+ metricScore := 0.40*fillominoNormalize(metrics["cells"], 25, 144) +131+ 0.25*(1-fillominoNormalize(metrics["given_ratio"], 0.52, 0.70)) +132+ 0.20*fillominoNormalize(metrics["max_region"], 5, 9) +133+ 0.15*fillominoNormalize(metrics["unknown_count"], 8, 70)134+ targetScore := 1 - fillominoNormalize(metrics["target_ratio"], 0.56, 0.70)135+ score := 0.75*targetScore + 0.25*metricScore136+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))137+}138+139+func fillominoNormalize(value, low, high float64) float64 {140+ if high <= low || value <= low {141+ return 0142+ }143+ if value >= high {144+ return 1145+ }146+ return (value - low) / (high - low)147+}148+149+func fillominoRatio(numerator, denominator int) float64 {150+ if denominator == 0 {151+ return 0152+ }153+ return float64(numerator) / float64(denominator)154+}
A games/fillomino/elo_test.go +70 -0
@@ -0,0 +1,70 @@
1+package fillomino2+3+import (4+ "reflect"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/difficulty"8+)9+10+func TestSpawnEloRejectsInvalidElo(t *testing.T) {11+ mode := NewMode("Elo", "test", 6, 6, 0.6)12+ gamer, report, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1)13+ if err == nil {14+ t.Fatal("SpawnElo returned nil error")15+ }16+ if gamer != nil {17+ t.Fatalf("gamer = %#v, want nil", gamer)18+ }19+ if !reflect.DeepEqual(report, difficulty.Report{}) {20+ t.Fatalf("report = %#v, want zero report", report)21+ }22+}23+24+func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {25+ mode := NewMode("Elo", "test", 6, 6, 0.6)26+ gameA, reportA, err := mode.SpawnElo("same-seed", 1300)27+ if err != nil {28+ t.Fatal(err)29+ }30+ gameB, reportB, err := mode.SpawnElo("same-seed", 1300)31+ if err != nil {32+ t.Fatal(err)33+ }34+ saveA, err := gameA.GetSave()35+ if err != nil {36+ t.Fatal(err)37+ }38+ saveB, err := gameB.GetSave()39+ if err != nil {40+ t.Fatal(err)41+ }42+ if string(saveA) != string(saveB) {43+ t.Fatalf("SpawnElo saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)44+ }45+ if !reflect.DeepEqual(reportA, reportB) {46+ t.Fatalf("reports differ:\n%#v\n%#v", reportA, reportB)47+ }48+}49+50+func TestSpawnEloReportPopulated(t *testing.T) {51+ mode := NewMode("Elo", "test", 6, 6, 0.6)52+ _, report, err := mode.SpawnElo("report-seed", 1800)53+ if err != nil {54+ t.Fatal(err)55+ }56+ if report.TargetElo != 1800 {57+ t.Fatalf("TargetElo = %d, want 1800", report.TargetElo)58+ }59+ if err := difficulty.ValidateElo(report.ActualElo); err != nil {60+ t.Fatalf("ActualElo = %d is invalid: %v", report.ActualElo, err)61+ }62+ if report.Confidence == "" {63+ t.Fatal("Confidence is empty")64+ }65+ for _, key := range []string{"width", "height", "cells", "given_count", "unknown_count", "given_ratio", "target_ratio", "max_region", "solution_count"} {66+ if _, ok := report.Metrics[key]; !ok {67+ t.Fatalf("metric %q missing from %+v", key, report.Metrics)68+ }69+ }70+}
M games/fillomino/gamemode.go +16 -1
M games/hashiwokakero/README.md +5 -0
A games/hashiwokakero/elo.go +155 -0
@@ -0,0 +1,155 @@
1+package hashiwokakero2+3+import (4+ "crypto/sha256"5+ "encoding/binary"6+ "errors"7+ "math"8+ "math/rand/v2"9+ "strconv"10+11+ "github.com/FelineStateMachine/puzzletea/difficulty"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+var _ game.EloSpawner = HashiMode{}16+17+func (h HashiMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18+ if err := difficulty.ValidateElo(elo); err != nil {19+ return nil, difficulty.Report{}, err20+ }21+22+ mode := hashiModeForElo(h, elo)23+ var bestPuzzle Puzzle24+ var bestReport difficulty.Report25+ haveBest := false26+ var lastErr error27+ for candidate := range difficulty.CandidateCount(elo) {28+ puzzle, err := GeneratePuzzleSeeded(mode, hashiEloRNG(hashiCandidateSeed(seed, candidate), elo))29+ if err != nil {30+ lastErr = err31+ continue32+ }33+ report := hashiDifficultyReport(elo, mode, puzzle)34+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {35+ bestPuzzle = puzzle36+ bestReport = report37+ haveBest = true38+ }39+ }40+ if !haveBest {41+ if lastErr == nil {42+ lastErr = errors.New("unable to generate Elo hashi")43+ }44+ return nil, difficulty.Report{}, lastErr45+ }46+ return New(mode, bestPuzzle), bestReport, nil47+}48+49+func hashiModeForElo(base HashiMode, elo difficulty.Elo) HashiMode {50+ score := difficulty.Score01(elo)51+ density := 0.11 + score*0.2952+ cells := base.Width * base.Height53+ target := int(math.Round(float64(cells) * density))54+ minIslands := max(3, target-2)55+ maxIslands := min(cells, target+2)56+ if minIslands > maxIslands {57+ minIslands = maxIslands58+ }59+60+ mode := base61+ mode.MinIslands = minIslands62+ mode.MaxIslands = maxIslands63+ return mode64+}65+66+func hashiEloRNG(seed string, elo difficulty.Elo) *rand.Rand {67+ gameID := string(Definition.ID)68+ if gameID == "" {69+ gameID = "hashiwokakero"70+ }71+ sum := sha256.Sum256([]byte(gameID + "\x00" + seed + "\x00" + strconv.Itoa(int(elo))))72+ return rand.New(rand.NewPCG(73+ binary.LittleEndian.Uint64(sum[0:8]),74+ binary.LittleEndian.Uint64(sum[8:16]),75+ ))76+}77+78+func hashiCandidateSeed(seed string, candidate int) string {79+ if candidate == 0 {80+ return seed81+ }82+ return seed + "\x00candidate:" + strconv.Itoa(candidate)83+}84+85+func hashiDifficultyReport(target difficulty.Elo, mode HashiMode, puzzle Puzzle) difficulty.Report {86+ metrics := hashiDifficultyMetrics(mode, puzzle)87+ return difficulty.Report{88+ TargetElo: target,89+ ActualElo: hashiActualElo(metrics),90+ Confidence: difficulty.ConfidenceMedium,91+ Metrics: metrics,92+ }93+}94+95+func hashiDifficultyMetrics(mode HashiMode, puzzle Puzzle) difficulty.Metrics {96+ cells := puzzle.Width * puzzle.Height97+ islands := len(puzzle.Islands)98+ totalRequired := 099+ maxRequired := 0100+ highRequired := 0101+ for _, island := range puzzle.Islands {102+ totalRequired += island.Required103+ if island.Required > maxRequired {104+ maxRequired = island.Required105+ }106+ if island.Required >= 5 {107+ highRequired++108+ }109+ }110+111+ connectablePairs := len(connectableIslandPairs(&puzzle))112+ return difficulty.Metrics{113+ "width": float64(puzzle.Width),114+ "height": float64(puzzle.Height),115+ "cells": float64(cells),116+ "islands": float64(islands),117+ "island_density": hashiRatio(islands, cells),118+ "target_minlands": float64(mode.MinIslands),119+ "target_maxlands": float64(mode.MaxIslands),120+ "total_required": float64(totalRequired),121+ "avg_required": hashiRatio(totalRequired, islands),122+ "max_required": float64(maxRequired),123+ "high_required": float64(highRequired),124+ "connectable_pairs": float64(connectablePairs),125+ "pair_density": hashiRatio(connectablePairs, max(1, islands)),126+ }127+}128+129+func hashiActualElo(metrics difficulty.Metrics) difficulty.Elo {130+ score := 0.24*normalizeHashi(metrics["width"], 7, 13) +131+ 0.20*normalizeHashi(metrics["island_density"], 0.14, 0.42) +132+ 0.17*normalizeHashi(metrics["total_required"]/metrics["cells"], 0.16, 0.58) +133+ 0.15*normalizeHashi(metrics["avg_required"], 1.6, 5.0) +134+ 0.13*normalizeHashi(metrics["pair_density"], 1.0, 3.5) +135+ 0.11*normalizeHashi(metrics["high_required"], 0, metrics["islands"]*0.45)136+137+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))138+}139+140+func normalizeHashi(value, low, high float64) float64 {141+ if high <= low || value <= low {142+ return 0143+ }144+ if value >= high {145+ return 1146+ }147+ return (value - low) / (high - low)148+}149+150+func hashiRatio(numerator, denominator int) float64 {151+ if denominator == 0 {152+ return 0153+ }154+ return float64(numerator) / float64(denominator)155+}
A games/hashiwokakero/elo_test.go +133 -0
@@ -0,0 +1,133 @@
1+package hashiwokakero2+3+import (4+ "reflect"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/difficulty"8+)9+10+func TestSpawnEloRejectsInvalidElo(t *testing.T) {11+ mode := NewMode("Elo", "test", 7, 7, 8, 10)12+13+ for _, elo := range []difficulty.Elo{difficulty.MinElo - 1, difficulty.SoftCapElo + 1} {14+ gamer, report, err := mode.SpawnElo("seed", elo)15+ if err == nil {16+ t.Fatalf("SpawnElo(%d) error = nil, want invalid Elo error", elo)17+ }18+ if gamer != nil {19+ t.Fatalf("SpawnElo(%d) gamer = %#v, want nil", elo, gamer)20+ }21+ if !reflect.DeepEqual(report, difficulty.Report{}) {22+ t.Fatalf("SpawnElo(%d) report = %#v, want zero report", elo, report)23+ }24+ }25+}26+27+func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {28+ mode := NewMode("Elo", "test", 7, 7, 8, 10)29+ const target = difficulty.Elo(1700)30+31+ gameA, reportA, err := mode.SpawnElo("same-seed", target)32+ if err != nil {33+ t.Fatalf("first SpawnElo returned error: %v", err)34+ }35+ gameB, reportB, err := mode.SpawnElo("same-seed", target)36+ if err != nil {37+ t.Fatalf("second SpawnElo returned error: %v", err)38+ }39+40+ saveA, err := gameA.GetSave()41+ if err != nil {42+ t.Fatalf("first GetSave returned error: %v", err)43+ }44+ saveB, err := gameB.GetSave()45+ if err != nil {46+ t.Fatalf("second GetSave returned error: %v", err)47+ }48+49+ if string(saveA) != string(saveB) {50+ t.Fatalf("SpawnElo saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)51+ }52+ if !reflect.DeepEqual(reportA, reportB) {53+ t.Fatalf("SpawnElo reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)54+ }55+}56+57+func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {58+ mode := NewMode("Elo", "test", 7, 7, 8, 10)59+ const target = difficulty.Elo(2100)60+61+ gamer, report, err := mode.SpawnElo("report-fields", target)62+ if err != nil {63+ t.Fatalf("SpawnElo returned error: %v", err)64+ }65+ if gamer == nil {66+ t.Fatal("SpawnElo returned nil gamer")67+ }68+ if report.TargetElo != target {69+ t.Fatalf("TargetElo = %d, want %d", report.TargetElo, target)70+ }71+ if err := difficulty.ValidateElo(report.ActualElo); err != nil {72+ t.Fatalf("ActualElo = %d is invalid: %v", report.ActualElo, err)73+ }74+ if report.Confidence != difficulty.ConfidenceMedium {75+ t.Fatalf("Confidence = %q, want %q", report.Confidence, difficulty.ConfidenceMedium)76+ }77+78+ requiredMetrics := map[string]float64{79+ "width": 1,80+ "height": 1,81+ "cells": 1,82+ "islands": 1,83+ "island_density": 0,84+ "target_minlands": 1,85+ "target_maxlands": 1,86+ "total_required": 1,87+ "avg_required": 1,88+ "max_required": 1,89+ "high_required": 0,90+ "connectable_pairs": 1,91+ "pair_density": 1,92+ }93+ for key, min := range requiredMetrics {94+ got, ok := report.Metrics[key]95+ if !ok {96+ t.Fatalf("Metrics[%q] missing from %#v", key, report.Metrics)97+ }98+ if got < min {99+ t.Fatalf("Metrics[%q] = %v, want >= %v", key, got, min)100+ }101+ }102+ if report.Metrics["island_density"] > 1 {103+ t.Fatalf("island_density = %v, want <= 1", report.Metrics["island_density"])104+ }105+}106+107+func TestSpawnEloPreservesSelectedDimensions(t *testing.T) {108+ mode := NewMode("Easy 7x7", "7x7 grid with 8-10 islands.", 7, 7, 8, 10)109+ _, report, err := mode.SpawnElo("fixed-size", 2800)110+ if err != nil {111+ t.Fatalf("SpawnElo returned error: %v", err)112+ }113+ if got, want := report.Metrics["width"], 7.0; got != want {114+ t.Fatalf("width metric = %.0f, want %.0f", got, want)115+ }116+ if got, want := report.Metrics["height"], 7.0; got != want {117+ t.Fatalf("height metric = %.0f, want %.0f", got, want)118+ }119+}120+121+func TestModeDefinitionsIncludePresetElo(t *testing.T) {122+ if len(ModeDefinitions) != len(Modes) {123+ t.Fatalf("ModeDefinitions length = %d, want %d", len(ModeDefinitions), len(Modes))124+ }125+ for i, def := range ModeDefinitions {126+ if def.PresetElo == nil {127+ t.Fatalf("ModeDefinitions[%d].PresetElo is nil", i)128+ }129+ if err := difficulty.ValidateElo(*def.PresetElo); err != nil {130+ t.Fatalf("ModeDefinitions[%d].PresetElo = %d is invalid: %v", i, *def.PresetElo, err)131+ }132+ }133+}
M games/hashiwokakero/gamemode.go +19 -1
M games/hitori/README.md +5 -0
A games/hitori/elo.go +194 -0
@@ -0,0 +1,194 @@
1+package hitori2+3+import (4+ "context"5+ "crypto/sha256"6+ "encoding/binary"7+ "math"8+ "math/rand/v2"9+ "strconv"10+11+ "github.com/FelineStateMachine/puzzletea/difficulty"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+var _ game.CancellableEloSpawner = HitoriMode{}16+17+func (h HitoriMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18+ return h.SpawnEloContext(context.Background(), seed, elo)19+}20+21+func (h HitoriMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {22+ if err := difficulty.ValidateElo(elo); err != nil {23+ return nil, difficulty.Report{}, err24+ }25+ if err := ctx.Err(); err != nil {26+ return nil, difficulty.Report{}, err27+ }28+29+ mode := hitoriModeForElo(h, elo)30+ var puzzle grid31+ var err error32+ if elo >= 2400 {33+ puzzle = generateFastHitoriEloPuzzle(mode, hitoriEloRNG(seed+"\x00fallback", elo))34+ } else {35+ puzzle, err = GenerateSeededContext(ctx, mode.Size, mode.BlackRatio, hitoriEloRNG(seed, elo))36+ if err != nil {37+ if ctx.Err() == nil {38+ return nil, difficulty.Report{}, err39+ }40+ puzzle = generateFastHitoriEloPuzzle(mode, hitoriEloRNG(seed+"\x00fallback", elo))41+ }42+ }43+44+ gamer, err := New(mode, puzzle)45+ if err != nil {46+ return nil, difficulty.Report{}, err47+ }48+ if elo >= 2400 {49+ report := hitoriDifficultyReportWithoutCounting(elo, mode, puzzle)50+ return gamer, report, nil51+ }52+ report := hitoriDifficultyReport(ctx, elo, mode, puzzle)53+ return gamer, report, nil54+}55+56+func hitoriModeForElo(base HitoriMode, elo difficulty.Elo) HitoriMode {57+ score := difficulty.Score01(elo)58+ blackRatio := 0.32 - score*0.0459+60+ mode := base61+ mode.BlackRatio = blackRatio62+ return mode63+}64+65+func hitoriEloRNG(seed string, elo difficulty.Elo) *rand.Rand {66+ sum := sha256.Sum256([]byte("hitori\x00" + seed + "\x00" + strconv.Itoa(int(elo))))67+ return rand.New(rand.NewPCG(68+ binary.LittleEndian.Uint64(sum[0:8]),69+ binary.LittleEndian.Uint64(sum[8:16]),70+ ))71+}72+73+func generateFastHitoriEloPuzzle(mode HitoriMode, rng *rand.Rand) grid {74+ baseGrid := generateLatinSquareSeeded(mode.Size, rng)75+ mask := generateValidMaskSeeded(mode.Size, mode.BlackRatio, rng)76+ return constructPuzzleSeeded(baseGrid, mask, rng)77+}78+79+func hitoriDifficultyReport(ctx context.Context, target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {80+ metrics := hitoriDifficultyMetrics(ctx, mode, puzzle)81+ confidence := difficulty.ConfidenceMedium82+ if metrics["solution_count"] < 0 {83+ confidence = difficulty.ConfidenceLow84+ }85+ return difficulty.Report{86+ TargetElo: target,87+ ActualElo: hitoriActualElo(metrics),88+ Confidence: confidence,89+ Metrics: metrics,90+ }91+}92+93+func hitoriDifficultyReportWithoutCounting(target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {94+ metrics := hitoriDifficultyMetricsWithoutCounting(mode, puzzle)95+ metrics["solution_count"] = -196+ metrics["solver_limited"] = 197+ return difficulty.Report{98+ TargetElo: target,99+ ActualElo: hitoriActualElo(metrics),100+ Confidence: difficulty.ConfidenceLow,101+ Metrics: metrics,102+ }103+}104+105+func hitoriDifficultyMetrics(ctx context.Context, mode HitoriMode, puzzle grid) difficulty.Metrics {106+ metrics := hitoriDifficultyMetricsWithoutCounting(mode, puzzle)107+ metrics["solution_count"] = float64(countPuzzleSolutionsContext(ctx, puzzle, len(puzzle), 2))108+ return metrics109+}110+111+func hitoriDifficultyMetricsWithoutCounting(mode HitoriMode, puzzle grid) difficulty.Metrics {112+ size := len(puzzle)113+ cells := size * size114+ duplicateCells, duplicateGroups := hitoriDuplicatePressure(puzzle)115+116+ return difficulty.Metrics{117+ "size": float64(size),118+ "cells": float64(cells),119+ "target_black_pct": mode.BlackRatio,120+ "duplicate_cells": float64(duplicateCells),121+ "duplicate_groups": float64(duplicateGroups),122+ "duplicate_pct": hitoriRatio(duplicateCells, cells),123+ }124+}125+126+func hitoriDuplicatePressure(puzzle grid) (int, int) {127+ duplicateCells := 0128+ duplicateGroups := 0129+130+ for _, row := range puzzle {131+ cells, groups := hitoriDuplicateLinePressure(row)132+ duplicateCells += cells133+ duplicateGroups += groups134+ }135+136+ size := len(puzzle)137+ for x := range size {138+ col := make([]rune, size)139+ for y := range size {140+ col[y] = puzzle[y][x]141+ }142+ cells, groups := hitoriDuplicateLinePressure(col)143+ duplicateCells += cells144+ duplicateGroups += groups145+ }146+147+ return duplicateCells, duplicateGroups148+}149+150+func hitoriDuplicateLinePressure(line []rune) (int, int) {151+ counts := make(map[rune]int, len(line))152+ for _, value := range line {153+ counts[value]++154+ }155+156+ cells := 0157+ groups := 0158+ for _, count := range counts {159+ if count < 2 {160+ continue161+ }162+ cells += count163+ groups++164+ }165+ return cells, groups166+}167+168+func hitoriActualElo(metrics difficulty.Metrics) difficulty.Elo {169+ metricScore := 0.50*hitoriNormalize(metrics["size"], 5, 12) +170+ 0.28*hitoriNormalize(metrics["duplicate_pct"], 0.20, 1.20) +171+ 0.17*(1-hitoriNormalize(metrics["target_black_pct"], 0.27, 0.32)) +172+ 0.05*hitoriNormalize(metrics["duplicate_groups"], 4, 36)173+ targetScore := 1 - hitoriNormalize(metrics["target_black_pct"], 0.28, 0.32)174+ score := 0.50*targetScore + 0.50*metricScore175+176+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))177+}178+179+func hitoriNormalize(value, low, high float64) float64 {180+ if high <= low || value <= low {181+ return 0182+ }183+ if value >= high {184+ return 1185+ }186+ return (value - low) / (high - low)187+}188+189+func hitoriRatio(numerator, denominator int) float64 {190+ if denominator == 0 {191+ return 0192+ }193+ return float64(numerator) / float64(denominator)194+}
A games/hitori/elo_test.go +110 -0
@@ -0,0 +1,110 @@
1+package hitori2+3+import (4+ "context"5+ "reflect"6+ "testing"7+8+ "github.com/FelineStateMachine/puzzletea/difficulty"9+)10+11+func TestSpawnEloRejectsInvalidElo(t *testing.T) {12+ mode := NewMode("Test", "Test mode.", 5, 0.32)13+14+ gamer, report, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1)15+ if err == nil {16+ t.Fatal("SpawnElo returned nil error for invalid Elo")17+ }18+ if gamer != nil {19+ t.Fatalf("SpawnElo gamer = %#v, want nil", gamer)20+ }21+ if !reflect.DeepEqual(report, difficulty.Report{}) {22+ t.Fatalf("SpawnElo report = %#v, want zero report", report)23+ }24+}25+26+func TestSpawnEloContextCanceled(t *testing.T) {27+ mode := NewMode("Test", "Test mode.", 5, 0.32)28+ ctx, cancel := context.WithCancel(context.Background())29+ cancel()30+31+ gamer, report, err := mode.SpawnEloContext(ctx, "seed", 1200)32+ if err == nil {33+ t.Fatal("SpawnEloContext returned nil error for canceled context")34+ }35+ if gamer != nil {36+ t.Fatalf("gamer = %#v, want nil", gamer)37+ }38+ if !reflect.DeepEqual(report, difficulty.Report{}) {39+ t.Fatalf("report = %#v, want zero report", report)40+ }41+}42+43+func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {44+ mode := NewMode("Test", "Test mode.", 5, 0.32)45+ target := difficulty.Elo(1500)46+47+ gamerA, reportA, err := mode.SpawnElo("same-seed", target)48+ if err != nil {49+ t.Fatalf("first SpawnElo returned error: %v", err)50+ }51+ gamerB, reportB, err := mode.SpawnElo("same-seed", target)52+ if err != nil {53+ t.Fatalf("second SpawnElo returned error: %v", err)54+ }55+56+ saveA, err := gamerA.GetSave()57+ if err != nil {58+ t.Fatalf("first GetSave returned error: %v", err)59+ }60+ saveB, err := gamerB.GetSave()61+ if err != nil {62+ t.Fatalf("second GetSave returned error: %v", err)63+ }64+ if string(saveA) != string(saveB) {65+ t.Fatalf("SpawnElo saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)66+ }67+ if !reflect.DeepEqual(reportA, reportB) {68+ t.Fatalf("SpawnElo reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)69+ }70+}71+72+func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {73+ mode := NewMode("Test", "Test mode.", 5, 0.32)74+ target := difficulty.Elo(2200)75+76+ gamer, report, err := mode.SpawnElo("report-fields", target)77+ if err != nil {78+ t.Fatalf("SpawnElo returned error: %v", err)79+ }80+ if gamer == nil {81+ t.Fatal("SpawnElo returned nil gamer")82+ }83+ if report.TargetElo != target {84+ t.Fatalf("TargetElo = %d, want %d", report.TargetElo, target)85+ }86+ if report.ActualElo < difficulty.MinElo || report.ActualElo > difficulty.SoftCapElo {87+ t.Fatalf("ActualElo = %d, want in supported range", report.ActualElo)88+ }89+ if report.Confidence != difficulty.ConfidenceMedium {90+ t.Fatalf("Confidence = %q, want %q", report.Confidence, difficulty.ConfidenceMedium)91+ }92+93+ requiredMetrics := []string{94+ "size",95+ "cells",96+ "target_black_pct",97+ "duplicate_cells",98+ "duplicate_groups",99+ "duplicate_pct",100+ "solution_count",101+ }102+ for _, name := range requiredMetrics {103+ if _, ok := report.Metrics[name]; !ok {104+ t.Fatalf("report metric %q missing from %#v", name, report.Metrics)105+ }106+ }107+ if report.Metrics["solution_count"] != 1 {108+ t.Fatalf("solution_count = %v, want 1", report.Metrics["solution_count"])109+ }110+}
M games/hitori/gamemode.go +19 -1
M games/hitori/generator.go +44 -14
@@ -1,8 +1,10 @@
11 package hitori22 33 import (4+ "context"45 "errors"56 "math/rand/v2"7+ "sort"68 )79 810 type cellPos struct{ x, y int }
@@ -224,10 +226,7 @@ func constructPuzzleSeeded(baseGrid grid, mask [][]bool, rng *rand.Rand) grid {
224226 return puzzle225227 }226228 227-// refinePuzzleSeeded tries different number assignments for black cells to achieve228-// a unique solution. It iterates through each black cell trying all possible229-// duplicate values, checking if the resulting puzzle has exactly one solution.230-func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (grid, bool) {229+func refinePuzzleSeededContext(ctx context.Context, puzzle grid, mask [][]bool, size int, rng *rand.Rand) (grid, bool) {231230 // Collect black cell positions.232231 var blackCells []cellPos233232 for y := range size {
@@ -239,13 +238,16 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
239238 }240239 241240 if len(blackCells) == 0 {242- return puzzle, countPuzzleSolutions(puzzle, size, 2) == 1241+ return puzzle, countPuzzleSolutionsContext(ctx, puzzle, size, 2) == 1243242 }244243 245244 // Try to refine each black cell's value to reduce solution count.246245 refined := puzzle.clone()247- currentCount := countPuzzleSolutions(refined, size, 3)246+ currentCount := countPuzzleSolutionsContext(ctx, refined, size, 3)248247 for _, bc := range blackCells {248+ if ctx.Err() != nil {249+ return refined, false250+ }249251 // Collect candidate numbers for this black cell.250252 candidates := cellCandidates(refined, mask, size, bc.x, bc.y)251253 rankRefinementCandidates(refined, mask, size, bc.x, bc.y, candidates, rng)
@@ -258,7 +260,7 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
258260 continue259261 }260262 refined[bc.y][bc.x] = num261- c := countPuzzleSolutions(refined, size, 3)263+ c := countPuzzleSolutionsContext(ctx, refined, size, 3)262264 if c == 1 {263265 return refined, true264266 }
@@ -275,7 +277,7 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
275277 }276278 }277279 278- return refined, countPuzzleSolutions(refined, size, 2) == 1280+ return refined, countPuzzleSolutionsContext(ctx, refined, size, 2) == 1279281 }280282 281283 func rankRefinementCandidates(
@@ -341,6 +343,9 @@ func cellCandidates(puzzle grid, mask [][]bool, size, x, y int) []rune {
341343 for num := range seen {342344 result = append(result, num)343345 }346+ sort.Slice(result, func(i, j int) bool {347+ return result[i] < result[j]348+ })344349 return result345350 }346351
@@ -354,20 +359,28 @@ const (
354359 )355360 356361 func countPuzzleSolutions(puzzle grid, size, limit int) int {362+ return countPuzzleSolutionsContext(context.Background(), puzzle, size, limit)363+}364+365+func countPuzzleSolutionsContext(ctx context.Context, puzzle grid, size, limit int) int {357366 st := make([][]solverState, size)358367 for y := range size {359368 st[y] = make([]solverState, size)360369 }361370 order := rowMajorOrder(size)362- return solveBT(puzzle, st, order, 0, limit)371+ return solveBTContext(ctx, puzzle, st, order, 0, limit)363372 }364373 365-func solveBT(374+func solveBTContext(375+ ctx context.Context,366376 puzzle grid,367377 st [][]solverState,368378 order []cellPos,369379 pos, limit int,370380 ) int {381+ if ctx.Err() != nil {382+ return -1383+ }371384 size := len(st)372385 if pos == len(order) {373386 marks := stateToMarks(st, size)
@@ -392,8 +405,13 @@ func solveBT(
392405 393406 if whiteOK {394407 st[y][x] = white395- count += solveBT(puzzle, st, order, pos+1, limit-count)408+ n := solveBTContext(ctx, puzzle, st, order, pos+1, limit-count)396409 st[y][x] = unknown410+ if n < 0 {411+ order[pos], order[nextIdx] = order[nextIdx], order[pos]412+ return n413+ }414+ count += n397415 if count >= limit {398416 order[pos], order[nextIdx] = order[nextIdx], order[pos]399417 return count
@@ -402,8 +420,13 @@ func solveBT(
402420 403421 if blackOK {404422 st[y][x] = black405- count += solveBT(puzzle, st, order, pos+1, limit-count)423+ n := solveBTContext(ctx, puzzle, st, order, pos+1, limit-count)406424 st[y][x] = unknown425+ if n < 0 {426+ order[pos], order[nextIdx] = order[nextIdx], order[pos]427+ return n428+ }429+ count += n407430 if count >= limit {408431 order[pos], order[nextIdx] = order[nextIdx], order[pos]409432 return count
@@ -507,20 +530,27 @@ func Generate(size int, blackRatio float64) (grid, error) {
507530 }508531 509532 func GenerateSeeded(size int, blackRatio float64, rng *rand.Rand) (grid, error) {533+ return GenerateSeededContext(context.Background(), size, blackRatio, rng)534+}535+536+func GenerateSeededContext(ctx context.Context, size int, blackRatio float64, rng *rand.Rand) (grid, error) {510537 const maxAttempts = 200511538 512539 for range maxAttempts {540+ if err := ctx.Err(); err != nil {541+ return nil, err542+ }513543 baseGrid := generateLatinSquareSeeded(size, rng)514544 mask := generateValidMaskSeeded(size, blackRatio, rng)515545 puzzle := constructPuzzleSeeded(baseGrid, mask, rng)516546 517547 // First try the random construction.518- if countPuzzleSolutions(puzzle, size, 2) == 1 {548+ if countPuzzleSolutionsContext(ctx, puzzle, size, 2) == 1 {519549 return puzzle, nil520550 }521551 522552 // Refine by trying different values for black cells.523- refined, ok := refinePuzzleSeeded(puzzle, mask, size, rng)553+ refined, ok := refinePuzzleSeededContext(ctx, puzzle, mask, size, rng)524554 if ok {525555 return refined, nil526556 }
M games/lightsout/README.md +5 -0
A games/lightsout/elo.go +182 -0
@@ -0,0 +1,182 @@
1+package lightsout2+3+import (4+ "crypto/sha256"5+ "encoding/binary"6+ "math"7+ "math/rand/v2"8+ "strconv"9+10+ "github.com/FelineStateMachine/puzzletea/difficulty"11+ "github.com/FelineStateMachine/puzzletea/game"12+)13+14+var _ game.EloSpawner = Mode{}15+16+type eloSpec struct {17+ width int18+ height int19+}20+21+func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {22+ if err := difficulty.ValidateElo(elo); err != nil {23+ return nil, difficulty.Report{}, err24+ }25+26+ spec := lightsOutSpecForMode(m)27+ rng := lightsOutRandForElo(seed, elo)28+ grid, report := generateLightsOutEloGrid(spec, rng, elo)29+30+ mode := m31+32+ g := newFromGrid(mode.Width, mode.Height, grid)33+ return g, report, nil34+}35+36+func lightsOutSpecForMode(mode Mode) eloSpec {37+ return eloSpec{width: mode.Width, height: mode.Height}38+}39+40+func lightsOutRandForElo(seed string, elo difficulty.Elo) *rand.Rand {41+ sum := sha256.Sum256([]byte("lightsout\x00" + seed + "\x00" + strconv.Itoa(int(elo))))42+ return rand.New(rand.NewPCG(43+ binary.LittleEndian.Uint64(sum[0:8]),44+ binary.LittleEndian.Uint64(sum[8:16]),45+ ))46+}47+48+func generateLightsOutEloGrid(spec eloSpec, rng *rand.Rand, target difficulty.Elo) ([][]bool, difficulty.Report) {49+ best := GenerateSeeded(spec.width, spec.height, rng)50+ bestReport := scoreLightsOutGrid(target, best)51+52+ for range 23 {53+ candidate := GenerateSeeded(spec.width, spec.height, rng)54+ report := scoreLightsOutGrid(target, candidate)55+ if eloDistance(report.ActualElo, target) < eloDistance(bestReport.ActualElo, target) {56+ best = candidate57+ bestReport = report58+ }59+ }60+61+ return best, bestReport62+}63+64+func scoreLightsOutGrid(target difficulty.Elo, grid [][]bool) difficulty.Report {65+ metrics := lightsOutMetrics(grid)66+ score := 0.45*normalizeLightsOut(metrics["cells"], 9, 81) +67+ 0.35*normalizeLightsOut(metrics["min_solution_moves"], 1, 41) +68+ 0.20*normalizeLightsOut(metrics["lights_on"], 1, 81)69+70+ return difficulty.Report{71+ TargetElo: target,72+ ActualElo: difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo)))),73+ Confidence: difficulty.ConfidenceHigh,74+ Metrics: metrics,75+ }76+}77+78+func lightsOutMetrics(grid [][]bool) difficulty.Metrics {79+ height := len(grid)80+ width := 081+ if height > 0 {82+ width = len(grid[0])83+ }84+ cells := width * height85+ lightsOn := 086+ for y := range grid {87+ for x := range grid[y] {88+ if grid[y][x] {89+ lightsOn++90+ }91+ }92+ }93+94+ minMoves, solved := minSolutionMoves(grid)95+ solvable := 0.096+ if solved {97+ solvable = 198+ }99+100+ return difficulty.Metrics{101+ "width": float64(width),102+ "height": float64(height),103+ "cells": float64(cells),104+ "lights_on": float64(lightsOn),105+ "light_density": ratio(lightsOn, cells),106+ "min_solution_moves": float64(minMoves),107+ "move_density": ratio(minMoves, cells),108+ "solvable": solvable,109+ }110+}111+112+func minSolutionMoves(grid [][]bool) (int, bool) {113+ height := len(grid)114+ if height == 0 {115+ return 0, true116+ }117+ width := len(grid[0])118+ if width == 0 {119+ return 0, true120+ }121+122+ best := width*height + 1123+ for firstRowMask := range 1 << width {124+ work := copyBoolGrid(grid)125+ moves := 0126+127+ for x := range width {128+ if firstRowMask&(1<<x) != 0 {129+ Toggle(work, x, 0)130+ moves++131+ }132+ }133+134+ for y := 1; y < height; y++ {135+ for x := range width {136+ if work[y-1][x] {137+ Toggle(work, x, y)138+ moves++139+ }140+ }141+ }142+143+ if IsSolved(work) && moves < best {144+ best = moves145+ }146+ }147+148+ if best == width*height+1 {149+ return 0, false150+ }151+ return best, true152+}153+154+func copyBoolGrid(grid [][]bool) [][]bool {155+ copied := make([][]bool, len(grid))156+ for y := range grid {157+ copied[y] = make([]bool, len(grid[y]))158+ copy(copied[y], grid[y])159+ }160+ return copied161+}162+163+func normalizeLightsOut(value, minValue, maxValue float64) float64 {164+ if maxValue <= minValue {165+ return 0166+ }167+ return math.Max(0, math.Min(1, (value-minValue)/(maxValue-minValue)))168+}169+170+func ratio(numerator, denominator int) float64 {171+ if denominator == 0 {172+ return 0173+ }174+ return float64(numerator) / float64(denominator)175+}176+177+func eloDistance(a, b difficulty.Elo) difficulty.Elo {178+ if a > b {179+ return a - b180+ }181+ return b - a182+}
A games/lightsout/elo_test.go +121 -0
@@ -0,0 +1,121 @@
1+package lightsout2+3+import (4+ "fmt"5+ "reflect"6+ "testing"7+8+ "github.com/FelineStateMachine/puzzletea/difficulty"9+)10+11+func TestSpawnEloRejectsInvalidElo(t *testing.T) {12+ mode := NewMode("Elo", "test", 5, 5)13+14+ tests := []difficulty.Elo{15+ difficulty.MinElo - 1,16+ difficulty.SoftCapElo + 1,17+ }18+ for _, elo := range tests {19+ t.Run(fmt.Sprint(elo), func(t *testing.T) {20+ gamer, report, err := mode.SpawnElo("seed", elo)21+ if err == nil {22+ t.Fatalf("SpawnElo(%d) error = nil, want invalid Elo error", elo)23+ }24+ if gamer != nil {25+ t.Fatalf("SpawnElo(%d) gamer = %#v, want nil", elo, gamer)26+ }27+ if !reflect.DeepEqual(report, difficulty.Report{}) {28+ t.Fatalf("SpawnElo(%d) report = %#v, want zero report", elo, report)29+ }30+ })31+ }32+}33+34+func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {35+ mode := NewMode("Elo", "test", 5, 5)36+ const seed = "lightsout-deterministic"37+ const elo = difficulty.Elo(1700)38+39+ gamerA, reportA, err := mode.SpawnElo(seed, elo)40+ if err != nil {41+ t.Fatalf("first SpawnElo returned error: %v", err)42+ }43+ gamerB, reportB, err := mode.SpawnElo(seed, elo)44+ if err != nil {45+ t.Fatalf("second SpawnElo returned error: %v", err)46+ }47+48+ saveA, err := gamerA.(Model).GetSave()49+ if err != nil {50+ t.Fatalf("first GetSave returned error: %v", err)51+ }52+ saveB, err := gamerB.(Model).GetSave()53+ if err != nil {54+ t.Fatalf("second GetSave returned error: %v", err)55+ }56+57+ if string(saveA) != string(saveB) {58+ t.Fatalf("SpawnElo saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)59+ }60+ if !reflect.DeepEqual(reportA, reportB) {61+ t.Fatalf("report mismatch for same seed/Elo:\n%#v\n\n%#v", reportA, reportB)62+ }63+}64+65+func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {66+ mode := NewMode("Elo", "test", 5, 5)67+ const target = difficulty.Elo(2200)68+69+ gamer, report, err := mode.SpawnElo("report-fields", target)70+ if err != nil {71+ t.Fatalf("SpawnElo returned error: %v", err)72+ }73+ if gamer == nil {74+ t.Fatal("SpawnElo returned nil gamer")75+ }76+ if report.TargetElo != target {77+ t.Fatalf("TargetElo = %d, want %d", report.TargetElo, target)78+ }79+ if err := difficulty.ValidateElo(report.ActualElo); err != nil {80+ t.Fatalf("ActualElo = %d is invalid: %v", report.ActualElo, err)81+ }82+ if report.Confidence == "" {83+ t.Fatal("Confidence is empty")84+ }85+86+ requiredMetrics := map[string]float64{87+ "width": 1,88+ "height": 1,89+ "cells": 1,90+ "lights_on": 1,91+ "light_density": 0,92+ "min_solution_moves": 1,93+ "move_density": 0,94+ "solvable": 1,95+ }96+ for key, min := range requiredMetrics {97+ got, ok := report.Metrics[key]98+ if !ok {99+ t.Fatalf("Metrics[%q] missing from %#v", key, report.Metrics)100+ }101+ if got < min {102+ t.Fatalf("Metrics[%q] = %v, want >= %v", key, got, min)103+ }104+ }105+}106+107+func TestSpawnEloPreservesSelectedDimensions(t *testing.T) {108+ mode := NewMode("Easy", "3x3 grid", 3, 3)109+ for _, elo := range []difficulty.Elo{100, 2800} {110+ _, report, err := mode.SpawnElo("fixed-size", elo)111+ if err != nil {112+ t.Fatalf("SpawnElo(%d) returned error: %v", elo, err)113+ }114+ if got, want := report.Metrics["width"], 3.0; got != want {115+ t.Fatalf("SpawnElo(%d) width metric = %.0f, want %.0f", elo, got, want)116+ }117+ if got, want := report.Metrics["height"], 3.0; got != want {118+ t.Fatalf("SpawnElo(%d) height metric = %.0f, want %.0f", elo, got, want)119+ }120+ }121+}
M games/lightsout/gamemode.go +16 -1
M games/netwalk/README.md +5 -0
A games/netwalk/elo.go +176 -0
@@ -0,0 +1,176 @@
1+package netwalk2+3+import (4+ "crypto/sha256"5+ "encoding/binary"6+ "errors"7+ "math"8+ "math/rand/v2"9+ "strconv"10+11+ "github.com/FelineStateMachine/puzzletea/difficulty"12+ "github.com/FelineStateMachine/puzzletea/game"13+)14+15+func (m NetwalkMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {16+ if err := difficulty.ValidateElo(elo); err != nil {17+ return nil, difficulty.Report{}, err18+ }19+20+ mode := netwalkModeForElo(m, elo)21+ var bestPuzzle Puzzle22+ var bestReport difficulty.Report23+ haveBest := false24+ var lastErr error25+ for candidate := range difficulty.CandidateCount(elo) {26+ puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, netwalkEloRNG(netwalkCandidateSeed(seed, candidate), elo))27+ if err != nil {28+ lastErr = err29+ continue30+ }31+ report := netwalkDifficultyReport(elo, mode, puzzle)32+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {33+ bestPuzzle = puzzle34+ bestReport = report35+ haveBest = true36+ }37+ }38+ if !haveBest {39+ if lastErr == nil {40+ lastErr = errors.New("unable to generate Elo netwalk")41+ }42+ return nil, difficulty.Report{}, lastErr43+ }44+45+ gamer, err := New(mode, bestPuzzle)46+ if err != nil {47+ return nil, difficulty.Report{}, err48+ }49+ return gamer, bestReport, nil50+}51+52+func netwalkModeForElo(base NetwalkMode, elo difficulty.Elo) NetwalkMode {53+ score := difficulty.Score01(elo)54+ fillRatio := 0.34 + score*0.4455+ profile := netwalkProfileForElo(score)56+57+ mode := base58+ mode.FillRatio = fillRatio59+ mode.Profile = profile60+ return mode61+}62+63+func netwalkProfileForElo(score float64) generateProfile {64+ switch {65+ case score < 0.20:66+ return miniProfile67+ case score < 0.42:68+ return easyProfile69+ case score < 0.65:70+ return mediumProfile71+ case score < 0.86:72+ return hardProfile73+ default:74+ return expertProfile75+ }76+}77+78+func netwalkEloRNG(seed string, elo difficulty.Elo) *rand.Rand {79+ sum := sha256.Sum256([]byte("netwalk\x00" + seed + "\x00" + strconv.Itoa(int(elo))))80+ return rand.New(rand.NewPCG(81+ binary.LittleEndian.Uint64(sum[0:8]),82+ binary.LittleEndian.Uint64(sum[8:16]),83+ ))84+}85+86+func netwalkCandidateSeed(seed string, candidate int) string {87+ if candidate == 0 {88+ return seed89+ }90+ return seed + "\x00candidate:" + strconv.Itoa(candidate)91+}92+93+func netwalkDifficultyReport(target difficulty.Elo, mode NetwalkMode, puzzle Puzzle) difficulty.Report {94+ metrics := netwalkDifficultyMetrics(mode, puzzle)95+ return difficulty.Report{96+ TargetElo: target,97+ ActualElo: netwalkActualElo(metrics),98+ Confidence: difficulty.ConfidenceMedium,99+ Metrics: metrics,100+ }101+}102+103+func netwalkDifficultyMetrics(mode NetwalkMode, puzzle Puzzle) difficulty.Metrics {104+ state := analyzePuzzle(puzzle)105+ metrics := difficulty.Metrics{106+ "size": float64(puzzle.Size),107+ "cells": float64(puzzle.Size * puzzle.Size),108+ "active_tiles": float64(state.nonEmpty),109+ "density": safeFloatRatio(state.nonEmpty, puzzle.Size*puzzle.Size),110+ "target_density": mode.FillRatio,111+ "connected_tiles": float64(state.connected),112+ "dangling_edges": float64(state.dangling),113+ "locked_tiles": float64(state.locked),114+ }115+116+ rotationOptions := 0117+ for y := range puzzle.Size {118+ for x := range puzzle.Size {119+ t := puzzle.Tiles[y][x]120+ if !isActive(t) {121+ continue122+ }123+ rotationOptions += len(uniqueRotations(t.BaseMask))124+ switch degree(t.BaseMask) {125+ case 1:126+ metrics["leaf_tiles"]++127+ case 2:128+ if t.BaseMask == north|south || t.BaseMask == east|west {129+ metrics["straight_tiles"]++130+ } else {131+ metrics["elbow_tiles"]++132+ }133+ case 3:134+ metrics["tee_tiles"]++135+ case 4:136+ metrics["cross_tiles"]++137+ }138+ if t.Rotation != 0 {139+ metrics["initially_rotated_tiles"]++140+ }141+ }142+ }143+ metrics["rotation_options"] = float64(rotationOptions)144+ metrics["avg_rotation_options"] = safeFloatRatio(rotationOptions, state.nonEmpty)145+ return metrics146+}147+148+func netwalkActualElo(metrics difficulty.Metrics) difficulty.Elo {149+ metricScore := 0.24*normalizeFloat(metrics["size"], 5, 13) +150+ 0.20*normalizeFloat(metrics["density"], 0.20, 0.80) +151+ 0.18*normalizeFloat(metrics["tee_tiles"]+metrics["cross_tiles"], 0, 18) +152+ 0.14*normalizeFloat(metrics["elbow_tiles"], 0, 32) +153+ 0.14*normalizeFloat(metrics["avg_rotation_options"], 1, 4) +154+ 0.10*normalizeFloat(metrics["initially_rotated_tiles"], 0, metrics["active_tiles"])155+ targetScore := normalizeFloat(metrics["target_density"], 0.34, 0.78)156+ score := 0.45*targetScore + 0.55*metricScore157+158+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))159+}160+161+func normalizeFloat(value, low, high float64) float64 {162+ if high <= low || value <= low {163+ return 0164+ }165+ if value >= high {166+ return 1167+ }168+ return (value - low) / (high - low)169+}170+171+func safeFloatRatio(numerator, denominator int) float64 {172+ if denominator == 0 {173+ return 0174+ }175+ return float64(numerator) / float64(denominator)176+}
M games/netwalk/gamemode.go +16 -1

Not shown: this page already shows 6000 lines of diff.

M games/netwalk/netwalk_test.go +98 -0

Not shown: this page already shows 6000 lines of diff.

M games/nonogram/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/nonogram/elo.go +182 -0

Not shown: this page already shows 6000 lines of diff.

A games/nonogram/elo_test.go +136 -0

Not shown: this page already shows 6000 lines of diff.

M games/nonogram/gamemode.go +19 -1

Not shown: this page already shows 6000 lines of diff.

M games/nonogram/generator.go +25 -2

Not shown: this page already shows 6000 lines of diff.

M games/nurikabe/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/nurikabe/elo.go +173 -0

Not shown: this page already shows 6000 lines of diff.

A games/nurikabe/elo_test.go +89 -0

Not shown: this page already shows 6000 lines of diff.

M games/nurikabe/gamemode.go +19 -1

Not shown: this page already shows 6000 lines of diff.

M games/nurikabe/generator.go +8 -1

Not shown: this page already shows 6000 lines of diff.

M games/rippleeffect/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/rippleeffect/elo.go +218 -0

Not shown: this page already shows 6000 lines of diff.

M games/rippleeffect/gamemode.go +19 -1

Not shown: this page already shows 6000 lines of diff.

M games/rippleeffect/rippleeffect_test.go +115 -0

Not shown: this page already shows 6000 lines of diff.

M games/rippleeffect/solver.go +19 -4

Not shown: this page already shows 6000 lines of diff.

M games/shikaku/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/shikaku/elo.go +285 -0

Not shown: this page already shows 6000 lines of diff.

A games/shikaku/elo_test.go +111 -0

Not shown: this page already shows 6000 lines of diff.

M games/shikaku/gamemode.go +19 -1

Not shown: this page already shows 6000 lines of diff.

M games/spellpuzzle/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/spellpuzzle/elo.go +187 -0

Not shown: this page already shows 6000 lines of diff.

M games/spellpuzzle/gamemode.go +16 -1

Not shown: this page already shows 6000 lines of diff.

M games/spellpuzzle/spellpuzzle_test.go +75 -0

Not shown: this page already shows 6000 lines of diff.

M games/sudoku/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/sudoku/elo.go +169 -0

Not shown: this page already shows 6000 lines of diff.

A games/sudoku/elo_test.go +71 -0

Not shown: this page already shows 6000 lines of diff.

M games/sudoku/gamemode.go +16 -1

Not shown: this page already shows 6000 lines of diff.

M games/sudokurgb/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/sudokurgb/elo.go +167 -0

Not shown: this page already shows 6000 lines of diff.

A games/sudokurgb/elo_test.go +71 -0

Not shown: this page already shows 6000 lines of diff.

M games/sudokurgb/gamemode.go +16 -1

Not shown: this page already shows 6000 lines of diff.

M games/takuzu/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/takuzu/elo.go +205 -0

Not shown: this page already shows 6000 lines of diff.

A games/takuzu/elo_test.go +125 -0

Not shown: this page already shows 6000 lines of diff.

M games/takuzu/gamemode.go +19 -1

Not shown: this page already shows 6000 lines of diff.

M games/takuzuplus/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/takuzuplus/elo.go +245 -0

Not shown: this page already shows 6000 lines of diff.

A games/takuzuplus/elo_test.go +118 -0

Not shown: this page already shows 6000 lines of diff.

M games/takuzuplus/gamemode.go +19 -1

Not shown: this page already shows 6000 lines of diff.

M games/wordsearch/README.md +5 -0

Not shown: this page already shows 6000 lines of diff.

A games/wordsearch/elo.go +230 -0

Not shown: this page already shows 6000 lines of diff.

A games/wordsearch/elo_test.go +107 -0

Not shown: this page already shows 6000 lines of diff.

M games/wordsearch/gamemode.go +16 -1

Not shown: this page already shows 6000 lines of diff.

M puzzle/puzzle.go +104 -1

Not shown: this page already shows 6000 lines of diff.

M registry/registry.go +13 -10

Not shown: this page already shows 6000 lines of diff.

M registry/registry_test.go +25 -0

Not shown: this page already shows 6000 lines of diff.

M resolve/resolve.go +91 -12

Not shown: this page already shows 6000 lines of diff.

M resolve/resolve_test.go +40 -15

Not shown: this page already shows 6000 lines of diff.

M session/session.go +51 -0

Not shown: this page already shows 6000 lines of diff.

M session/session_test.go +14 -0

Not shown: this page already shows 6000 lines of diff.

M stats/stats.go +44 -9

Not shown: this page already shows 6000 lines of diff.

M stats/stats_test.go +41 -0

Not shown: this page already shows 6000 lines of diff.

M store/game_record.go +23 -18

Not shown: this page already shows 6000 lines of diff.

M store/migrate.go +106 -2

Not shown: this page already shows 6000 lines of diff.

M store/stats.go +8 -1

Not shown: this page already shows 6000 lines of diff.

M store/stats_test.go +39 -0

Not shown: this page already shows 6000 lines of diff.

M store/store.go +44 -12

Not shown: this page already shows 6000 lines of diff.

M store/store_test.go +26 -0

Not shown: this page already shows 6000 lines of diff.

M weekly/weekly_test.go +3 -5

Not shown: this page already shows 6000 lines of diff.