clone

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

Add Elo difficulty generation

Dami committed 4455328
commit
4455328a2b0b1e6728d1c4e92f8628165e972986
parent
ded640c8792e99517e56e4456167741daa373265
author
Dami <dami.etoile@gmail.com>
104 files changed, +7016 -132
MREADME.md55++-
Aapp/create_state.go379++++++++++++++++++
Aapp/create_state_test.go159+++++++
Aapp/handle_create.go114+++++
Aapp/handle_create_test.go176++++++++
Mapp/model.go6+-
Mapp/screen_core.go36+-
Aapp/screen_create.go648++++++++++++++++++++++++++++++
Aapp/screen_create_test.go257++++++++++++
Mapp/screen_menu.go4+-
Mapp/screens_test.go8+-
Mapp/session_controller.go26+-
Mapp/spawn.go20+
Mapp/view.go2+-
Mapp/view_test.go17+
Mcmd/config_test.go4+
Mcmd/new.go73+++-
Mcmd/new_export.go71+++-
Mcmd/new_export_test.go6+
Mconfig/config.go7+
Mconfig/config_test.go26+
Adifficulty/difficulty.go50++
Adifficulty/difficulty_test.go55+++
Mexport/pack/render.go4+
Mexport/pack/runtime.go33+-
Mexport/pack/spec.go57++-
Mexport/pdf/jsonl.go69+++-
Aexport/pdf/jsonl_difficulty_test.go92++++
Mexport/pdf/types.go3+
Mgame/gamer.go17+-
Mgameentry/gameentry.go22+
Mgames/fillomino/README.md5+
Agames/fillomino/elo.go116+++++
Agames/fillomino/elo_test.go70+++
Mgames/fillomino/gamemode.go17+-
Mgames/hashiwokakero/README.md5+
Agames/hashiwokakero/elo.go129++++++
Agames/hashiwokakero/elo_test.go119++++++
Mgames/hashiwokakero/gamemode.go20+-
Mgames/hitori/README.md5+
Agames/hitori/elo.go147+++++++
Agames/hitori/elo_test.go92++++
Mgames/hitori/gamemode.go20+-
Mgames/hitori/generator.go4+
Mgames/lightsout/README.md5+
Agames/lightsout/elo.go186+++++++++
Agames/lightsout/elo_test.go105+++++
Mgames/lightsout/gamemode.go17+-
Mgames/netwalk/README.md5+
Agames/netwalk/elo.go151+++++++
Mgames/netwalk/gamemode.go17+-
Mgames/netwalk/netwalk_test.go98+++++
Mgames/nonogram/README.md5+
Agames/nonogram/elo.go162++++++++
Agames/nonogram/elo_test.go101+++++
Mgames/nonogram/gamemode.go20+-
Mgames/nurikabe/README.md5+
Agames/nurikabe/elo.go159+++++++
Agames/nurikabe/elo_test.go89++++
Mgames/nurikabe/gamemode.go20+-
Mgames/rippleeffect/README.md5+
Agames/rippleeffect/elo.go206++++++++++
Mgames/rippleeffect/gamemode.go20+-
Mgames/rippleeffect/rippleeffect_test.go97++++
Mgames/shikaku/README.md5+
Agames/shikaku/elo.go255++++++++++++
Agames/shikaku/elo_test.go111+++++
Mgames/shikaku/gamemode.go20+-
Mgames/spellpuzzle/README.md5+
Agames/spellpuzzle/elo.go157+++++++
Mgames/spellpuzzle/gamemode.go17+-
Mgames/spellpuzzle/spellpuzzle_test.go75+++
Mgames/sudoku/README.md5+
Agames/sudoku/elo.go149+++++++
Agames/sudoku/elo_test.go71+++
Mgames/sudoku/gamemode.go17+-
Mgames/sudokurgb/README.md5+
Agames/sudokurgb/elo.go147+++++++
Agames/sudokurgb/elo_test.go71+++
Mgames/sudokurgb/gamemode.go17+-
Mgames/takuzu/README.md5+
Agames/takuzu/elo.go186+++++++++
Agames/takuzu/elo_test.go107+++++
Mgames/takuzu/gamemode.go20+-
Mgames/takuzuplus/README.md5+
Agames/takuzuplus/elo.go225++++++++++
Agames/takuzuplus/elo_test.go100+++++
Mgames/takuzuplus/gamemode.go20+-
Mgames/wordsearch/README.md5+
Agames/wordsearch/elo.go206++++++++++
Agames/wordsearch/elo_test.go107+++++
Mgames/wordsearch/gamemode.go17+-
Mpuzzle/puzzle.go17+-
Mresolve/resolve.go16+-
Msession/session.go49++
Msession/session_test.go14+
Mstats/stats.go18+-
Mstats/stats_test.go6+
Mstore/game_record.go39+-
Mstore/migrate.go49++-
Mstore/stats.go9+-
Mstore/stats_test.go39++
Mstore/store.go30+-
Mstore/store_test.go14+
M README.md +50 -5
@@ -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:
@@ -173,6 +200,10 @@ Font license note (Atkinson Hyperlegible Next):
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:
@@ -358,6 +389,7 @@ Create a directory (e.g., `mypuzzle/`) with these files:
358389 | `keys.go` | Game-specific `KeyMap` struct |359390 | `style.go` | lipgloss styling and rendering helpers |360391 | `generator.go` | Puzzle generation logic (if applicable) |392+| `elo.go` | Elo-targeted generation and `difficulty.Report` scoring |361393 | `grid.go` | Grid type and serialization (for grid-based games) |362394 | `help.md` | Embedded rules/help content wired into the runtime entry |363395 | `print_adapter.go` | Optional printable export adapter for JSONL/PDF support |
@@ -365,7 +397,8 @@ Create a directory (e.g., `mypuzzle/`) with these files:
365397 | `README.md` | Game docs: rules, controls table, modes table, quick start examples |366398 367399 Use `gameentry.BuildModeDefs(Modes)` and `gameentry.NewEntry(...)` so the368-package exposes both metadata and its validated runtime wiring.400+package exposes both metadata and its validated runtime wiring. If a mode has a401+preset Elo, its runtime mode must implement `game.EloSpawner`.369402 370403 ### 2. Wire it into the built-in registry371404
@@ -379,6 +412,7 @@ The game package's `Definition` owns:
379412 - description380413 - aliases381414 - menu modes415+- preset Elo values for modes382416 - daily-eligible modes383417 - help content384418 - save/import function
@@ -405,6 +439,17 @@ See any existing game package (e.g., `nonogram/`) for the full pattern, and `AGE
405439 406440 [MIT](LICENSE)407441 442+## Research References443+444+Elo difficulty scoring and game-specific heuristics were informed by public445+puzzle implementations and solver documentation, including Simon Tatham's446+[Portable Puzzle Collection](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/)447+and [source repository](https://git.tartarus.org/?p=simon/puzzles.git),448+[Copris puzzle solvers](https://cspsat.gitlab.io/copris-puzzles/),449+[QQWing Sudoku](https://qqwing.com/), Jaap Scherphuis'450+[Lights Out notes](https://www.jaapsch.net/puzzles/lights.htm), and public451+[Nikoli puzzle rules](https://www.nikoli.co.jp/en/puzzles/).452+408453 ## Built With409454 410455 - [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework
A app/create_state.go +379 -0
@@ -0,0 +1,379 @@
1+package app2+3+import (4+ "fmt"5+ "regexp"6+ "sort"7+ "strconv"8+ "strings"9+10+ "github.com/FelineStateMachine/puzzletea/config"11+ "github.com/FelineStateMachine/puzzletea/difficulty"12+ "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/FelineStateMachine/puzzletea/puzzle"14+ "github.com/FelineStateMachine/puzzletea/registry"15+16+ "charm.land/bubbles/v2/textinput"17+)18+19+const defaultCreateElo = 120020+21+var boardSizePattern = regexp.MustCompile(`(?i)(\d+)\s*[x×]\s*(\d+)`)22+23+type createState struct {24+ tree []createTreeNode25+ checked map[string]bool26+ expanded map[string]bool27+ cursor int28+ eloInput textinput.Model29+ seedInput textinput.Model30+ focus createFocus31+}32+33+type createFocus int34+35+const (36+ createFocusTree createFocus = iota37+ createFocusElo38+ createFocusSeed39+ createFocusGenerate40+)41+42+type createTreeNode struct {43+ id string44+ title string45+ leaf *createLeaf46+ children []createTreeNode47+}48+49+type createLeaf struct {50+ id string51+ title string52+ gameType string53+ modeTitle string54+ modes []registry.ModeEntry55+}56+57+type createVisibleNode struct {58+ node createTreeNode59+ depth int60+}61+62+func newCreateState(cfg config.CreateConfig, width int) createState {63+ elo := cfg.Elo64+ if err := difficulty.ValidateElo(difficulty.Elo(elo)); err != nil {65+ elo = defaultCreateElo66+ }67+68+ eloInput := textinput.New()69+ eloInput.Prompt = ""70+ eloInput.CharLimit = 471+ eloInput.SetWidth(8)72+ eloInput.SetValue(strconv.Itoa(elo))73+74+ seedInput := textinput.New()75+ seedInput.Prompt = ""76+ seedInput.Placeholder = "optional seed"77+ seedInput.CharLimit = 6478+ seedInput.SetWidth(min(width, 48))79+80+ state := createState{81+ tree: buildCreateTree(registry.Entries()),82+ checked: make(map[string]bool),83+ expanded: make(map[string]bool),84+ eloInput: eloInput,85+ seedInput: seedInput,86+ focus: createFocusTree,87+ }88+ for _, id := range cfg.SelectedLeafIDs {89+ state.checked[id] = true90+ state.expandAncestors(id)91+ }92+ if len(state.checked) == 0 {93+ state.expandTopLevel()94+ }95+ return state96+}97+98+func buildCreateTree(entries []registry.Entry) []createTreeNode {99+ builders := make(map[string]*createNodeBuilder)100+ order := make([]string, 0, len(entries))101+ for _, entry := range entries {102+ topTitle, branchTitle := createGrouping(entry.Definition.Name)103+ topID := "group:" + puzzle.NormalizeName(topTitle)104+ builder, ok := builders[topID]105+ if !ok {106+ builder = &createNodeBuilder{id: topID, title: topTitle}107+ builders[topID] = builder108+ order = append(order, topID)109+ }110+ builder.addBranch(entry, branchTitle)111+ }112+113+ nodes := make([]createTreeNode, 0, len(order))114+ for _, id := range order {115+ nodes = append(nodes, builders[id].node())116+ }117+ return nodes118+}119+120+type createNodeBuilder struct {121+ id string122+ title string123+ branches []createTreeNode124+}125+126+func (b *createNodeBuilder) addBranch(entry registry.Entry, branchTitle string) {127+ leaves := createLeavesForEntry(entry, branchTitle)128+ if branchTitle == "" {129+ b.branches = append(b.branches, leaves...)130+ return131+ }132+133+ branchID := b.id + "/branch:" + puzzle.NormalizeName(branchTitle)134+ b.branches = append(b.branches, createTreeNode{135+ id: branchID,136+ title: branchTitle,137+ children: leaves,138+ })139+}140+141+func (b *createNodeBuilder) node() createTreeNode {142+ return createTreeNode{id: b.id, title: b.title, children: b.branches}143+}144+145+func createGrouping(gameType string) (topTitle, branchTitle string) {146+ switch gameType {147+ case "Takuzu", "Takuzu+":148+ return "Takuzu", gameType149+ case "Sudoku", "Sudoku RGB":150+ return "Sudoku", gameType151+ default:152+ return gameType, ""153+ }154+}155+156+func createLeavesForEntry(entry registry.Entry, branchTitle string) []createTreeNode {157+ groups := make(map[string][]registry.ModeEntry)158+ order := make([]string, 0, len(entry.Modes))159+ for _, mode := range entry.Modes {160+ if mode.Elo == nil {161+ continue162+ }163+ title := createLeafTitle(entry.Definition.Name, entry.Definition.Description, mode.Definition.Title, mode.Definition.Description)164+ if _, ok := groups[title]; !ok {165+ order = append(order, title)166+ }167+ groups[title] = append(groups[title], mode)168+ }169+170+ nodes := make([]createTreeNode, 0, len(order))171+ for _, title := range order {172+ id := createLeafID(entry.Definition.ID, branchTitle, title)173+ modes := groups[title]174+ nodes = append(nodes, createTreeNode{175+ id: id,176+ title: title,177+ leaf: &createLeaf{178+ id: id,179+ title: title,180+ gameType: entry.Definition.Name,181+ modeTitle: modes[0].Definition.Title,182+ modes: modes,183+ },184+ })185+ }186+ return nodes187+}188+189+func createLeafID(gameID puzzle.GameID, branchTitle, title string) string {190+ parts := []string{string(gameID)}191+ if branchTitle != "" {192+ parts = append(parts, puzzle.NormalizeName(branchTitle))193+ }194+ parts = append(parts, puzzle.NormalizeName(title))195+ return strings.Join(parts, "/")196+}197+198+func createLeafTitle(gameType, gameDescription, modeTitle, modeDescription string) string {199+ if size := firstBoardSize(modeTitle); size != "" {200+ return size201+ }202+ if size := firstBoardSize(modeDescription); size != "" {203+ return size204+ }205+ if gameType == "Sudoku RGB" {206+ return "9x9"207+ }208+ if size := firstBoardSize(gameDescription); size != "" {209+ return size210+ }211+ return modeTitle212+}213+214+func firstBoardSize(s string) string {215+ match := boardSizePattern.FindStringSubmatch(s)216+ if len(match) != 3 {217+ return ""218+ }219+ return match[1] + "x" + match[2]220+}221+222+func (s createState) selectedLeaves() []createLeaf {223+ leaves := make([]createLeaf, 0, len(s.checked))224+ var walk func([]createTreeNode)225+ walk = func(nodes []createTreeNode) {226+ for _, node := range nodes {227+ if node.leaf != nil && s.checked[node.leaf.id] {228+ leaves = append(leaves, *node.leaf)229+ }230+ walk(node.children)231+ }232+ }233+ walk(s.tree)234+ return leaves235+}236+237+func (s createState) selectedLeafIDs() []string {238+ ids := make([]string, 0, len(s.checked))239+ for id, checked := range s.checked {240+ if checked {241+ ids = append(ids, id)242+ }243+ }244+ sort.Strings(ids)245+ return ids246+}247+248+func (s createState) selectedCount() int {249+ count := 0250+ for _, checked := range s.checked {251+ if checked {252+ count++253+ }254+ }255+ return count256+}257+258+func (s createState) descendantLeafCounts(node createTreeNode) (selected, total int) {259+ var walk func(createTreeNode)260+ walk = func(node createTreeNode) {261+ if node.leaf != nil {262+ total++263+ if s.checked[node.leaf.id] {264+ selected++265+ }266+ return267+ }268+ for _, child := range node.children {269+ walk(child)270+ }271+ }272+ walk(node)273+ return selected, total274+}275+276+func (s *createState) toggleDescendantLeaves(node createTreeNode) {277+ selected, total := s.descendantLeafCounts(node)278+ if total == 0 {279+ return280+ }281+ checked := selected != total282+ var walk func(createTreeNode)283+ walk = func(node createTreeNode) {284+ if node.leaf != nil {285+ s.checked[node.leaf.id] = checked286+ return287+ }288+ for _, child := range node.children {289+ walk(child)290+ }291+ }292+ walk(node)293+}294+295+func (s createState) seedEnabled() bool {296+ return s.selectedCount() == 1297+}298+299+func (s createState) elo() (difficulty.Elo, error) {300+ value, err := strconv.Atoi(strings.TrimSpace(s.eloInput.Value()))301+ if err != nil {302+ return 0, fmt.Errorf("Elo must be a number")303+ }304+ elo := difficulty.Elo(value)305+ if err := difficulty.ValidateElo(elo); err != nil {306+ return 0, err307+ }308+ return elo, nil309+}310+311+func (s createState) visibleNodes() []createVisibleNode {312+ visible := make([]createVisibleNode, 0)313+ var walk func([]createTreeNode, int)314+ walk = func(nodes []createTreeNode, depth int) {315+ for _, node := range nodes {316+ visible = append(visible, createVisibleNode{node: node, depth: depth})317+ if node.leaf == nil && s.expanded[node.id] {318+ walk(node.children, depth+1)319+ }320+ }321+ }322+ walk(s.tree, 0)323+ return visible324+}325+326+func (s *createState) expandTopLevel() {327+ for _, node := range s.tree {328+ s.expanded[node.id] = false329+ }330+}331+332+func (s *createState) expandAncestors(leafID string) {333+ var walk func([]createTreeNode, []string) bool334+ walk = func(nodes []createTreeNode, ancestors []string) bool {335+ for _, node := range nodes {336+ if node.id == leafID {337+ for _, id := range ancestors {338+ s.expanded[id] = true339+ }340+ return true341+ }342+ if walk(node.children, append(ancestors, node.id)) {343+ return true344+ }345+ }346+ return false347+ }348+ walk(s.tree, nil)349+}350+351+func (s createState) resolveLeafMode(leaf createLeaf, elo difficulty.Elo) (game.EloSpawner, string, error) {352+ if len(leaf.modes) == 0 {353+ return nil, "", fmt.Errorf("no Elo-capable modes for %s", leaf.title)354+ }355+ best := leaf.modes[0]356+ bestDistance := createPresetDistance(best, elo)357+ for _, mode := range leaf.modes[1:] {358+ distance := createPresetDistance(mode, elo)359+ if distance < bestDistance {360+ best = mode361+ bestDistance = distance362+ }363+ }364+ if best.Elo == nil {365+ return nil, "", fmt.Errorf("%s %s does not support Elo generation", leaf.gameType, leaf.title)366+ }367+ return best.Elo, best.Definition.Title, nil368+}369+370+func createPresetDistance(mode registry.ModeEntry, elo difficulty.Elo) int {371+ if mode.Definition.PresetElo == nil {372+ return 0373+ }374+ delta := int(*mode.Definition.PresetElo - elo)375+ if delta < 0 {376+ return -delta377+ }378+ return delta379+}
A app/create_state_test.go +159 -0
@@ -0,0 +1,159 @@
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/5x5"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+ if len(state.visibleNodes()) <= len(state.tree) {24+ t.Fatalf("expected restored leaf ancestors to be expanded")25+ }26+}27+28+func TestCreateStateDefaultStartsCollapsed(t *testing.T) {29+ state := newCreateState(config.CreateConfig{}, 80)30+31+ if len(state.visibleNodes()) != len(state.tree) {32+ t.Fatalf("visible node count = %d, want %d", len(state.visibleNodes()), len(state.tree))33+ }34+}35+36+func TestCreateTreeGroupsTakuzuAndSudokuVariants(t *testing.T) {37+ state := newCreateState(config.CreateConfig{}, 80)38+39+ takuzu := requireCreateNode(t, state.tree, "Takuzu")40+ if !hasCreateChild(takuzu, "Takuzu") || !hasCreateChild(takuzu, "Takuzu+") {41+ t.Fatalf("Takuzu group children = %#v, want Takuzu and Takuzu+", childTitles(takuzu))42+ }43+44+ sudoku := requireCreateNode(t, state.tree, "Sudoku")45+ if !hasCreateChild(sudoku, "Sudoku") || !hasCreateChild(sudoku, "Sudoku RGB") {46+ t.Fatalf("Sudoku group children = %#v, want Sudoku and Sudoku RGB", childTitles(sudoku))47+ }48+}49+50+func TestCreateTreeCollapsesNonogramByBoardSize(t *testing.T) {51+ state := newCreateState(config.CreateConfig{}, 80)52+ nonogram := requireCreateNode(t, state.tree, "Nonogram")53+54+ want := []string{"5x5", "10x10", "15x15", "20x20"}55+ got := childTitles(nonogram)56+ if len(got) != len(want) {57+ t.Fatalf("Nonogram leaves = %#v, want %#v", got, want)58+ }59+ for i := range want {60+ if got[i] != want[i] {61+ t.Fatalf("Nonogram leaves = %#v, want %#v", got, want)62+ }63+ }64+}65+66+func TestCreateResolveLeafModeUsesNearestPresetElo(t *testing.T) {67+ state := newCreateState(config.CreateConfig{}, 80)68+ nonogram := requireCreateNode(t, state.tree, "Nonogram")69+ leaf := requireCreateNode(t, nonogram.children, "5x5").leaf70+ if leaf == nil {71+ t.Fatal("5x5 node is not a leaf")72+ }73+74+ _, modeTitle, err := state.resolveLeafMode(*leaf, difficulty.Elo(700))75+ if err != nil {76+ t.Fatal(err)77+ }78+ if modeTitle != "Teaser" {79+ t.Fatalf("modeTitle = %q, want Teaser", modeTitle)80+ }81+}82+83+func TestCreateStateDescendantLeafCountsIncludeCollapsedDescendants(t *testing.T) {84+ state := newCreateState(config.CreateConfig{}, 80)85+ nonogram := requireCreateNode(t, state.tree, "Nonogram")86+ state.checked[nonogram.children[0].leaf.id] = true87+88+ selected, total := state.descendantLeafCounts(nonogram)89+ if selected != 1 || total != len(nonogram.children) {90+ t.Fatalf("descendant counts = %d/%d, want 1/%d", selected, total, len(nonogram.children))91+ }92+}93+94+func TestCreateStateToggleDescendantLeavesSelectsAllWhenAnyUnchecked(t *testing.T) {95+ state := newCreateState(config.CreateConfig{}, 80)96+ nonogram := requireCreateNode(t, state.tree, "Nonogram")97+ state.checked[nonogram.children[0].leaf.id] = true98+99+ state.toggleDescendantLeaves(nonogram)100+101+ selected, total := state.descendantLeafCounts(nonogram)102+ if selected != total || total == 0 {103+ t.Fatalf("descendant counts after select-all = %d/%d, want all selected", selected, total)104+ }105+}106+107+func TestCreateStateToggleDescendantLeavesClearsFullySelectedParent(t *testing.T) {108+ state := newCreateState(config.CreateConfig{}, 80)109+ nonogram := requireCreateNode(t, state.tree, "Nonogram")110+ state.toggleDescendantLeaves(nonogram)111+112+ state.toggleDescendantLeaves(nonogram)113+114+ selected, total := state.descendantLeafCounts(nonogram)115+ if selected != 0 || total == 0 {116+ t.Fatalf("descendant counts after clear = %d/%d, want 0 selected", selected, total)117+ }118+}119+120+func TestCreateStateToggleDescendantLeavesWorksForNestedVariantBranch(t *testing.T) {121+ state := newCreateState(config.CreateConfig{}, 80)122+ takuzu := requireCreateNode(t, state.tree, "Takuzu")123+ takuzuPlus := requireCreateNode(t, takuzu.children, "Takuzu+")124+125+ state.toggleDescendantLeaves(takuzuPlus)126+127+ selected, total := state.descendantLeafCounts(takuzuPlus)128+ if selected != total || total == 0 {129+ t.Fatalf("Takuzu+ descendant counts after toggle = %d/%d, want all selected", selected, total)130+ }131+}132+133+func requireCreateNode(t *testing.T, nodes []createTreeNode, title string) createTreeNode {134+ t.Helper()135+ for _, node := range nodes {136+ if node.title == title {137+ return node138+ }139+ }140+ t.Fatalf("missing create node %q in %#v", title, childTitles(createTreeNode{children: nodes}))141+ return createTreeNode{}142+}143+144+func hasCreateChild(node createTreeNode, title string) bool {145+ for _, child := range node.children {146+ if child.title == title {147+ return true148+ }149+ }150+ return false151+}152+153+func childTitles(node createTreeNode) []string {154+ titles := make([]string, 0, len(node.children))155+ for _, child := range node.children {156+ titles = append(titles, child.title)157+ }158+ return titles159+}
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.resolveLeafMode(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 +176 -0
@@ -0,0 +1,176 @@
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+ modeTitle: modeTitle,164+ modes: []registry.ModeEntry{{165+ Definition: puzzle.ModeDef{166+ Title: modeTitle,167+ PresetElo: ptrElo(1200),168+ },169+ Elo: spawner,170+ }},171+ }172+}173+174+func ptrElo(elo difficulty.Elo) *difficulty.Elo {175+ return &elo176+}
M app/model.go +4 -2
M app/screen_core.go +22 -14
@@ -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 }
@@ -125,15 +124,21 @@ type modeSelectEnterAction struct {
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.Definition.PresetElo != nil && a.mode.Elo != nil {138+ cmd := newSessionController(&m).startEloSpawn(a.mode.Elo, name, *a.mode.Definition.PresetElo, request)139+ return m, cmd140+ }141+ cmd := newSessionController(&m).startSpawn(a.mode.Spawner, 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 > 0 {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 +257 -0
@@ -0,0 +1,257 @@
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+ screen := createScreen{create: state}16+ parent := state.visibleNodes()[0].node17+ _, parentTotal := state.descendantLeafCounts(parent)18+19+ next, _, _ := screen.Update(tea.KeyPressMsg{Code: tea.KeySpace})20+ got := next.(createScreen)21+ if got.create.selectedCount() != parentTotal {22+ t.Fatalf("selected count after parent space = %d, want %d", got.create.selectedCount(), parentTotal)23+ }24+25+ next, _, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyEnter})26+ got = next.(createScreen)27+ got.create.cursor = 128+ next, _, _ = got.Update(tea.KeyPressMsg{Code: tea.KeySpace})29+ got = next.(createScreen)30+ if got.create.selectedCount() != parentTotal-1 {31+ t.Fatalf("selected count after leaf space = %d, want %d", got.create.selectedCount(), parentTotal-1)32+ }33+}34+35+func TestCreateScreenEnterExpandsAndCollapsesParent(t *testing.T) {36+ screen := createScreen{create: newCreateState(config.CreateConfig{}, 80)}37+ parentID := screen.create.visibleNodes()[0].node.id38+39+ next, _, _ := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})40+ expanded := next.(createScreen)41+ if !expanded.create.expanded[parentID] {42+ t.Fatal("expected parent to expand")43+ }44+ if expanded.create.selectedCount() != 0 {45+ t.Fatalf("selected count after parent enter = %d, want 0", expanded.create.selectedCount())46+ }47+48+ next, _, _ = expanded.Update(tea.KeyPressMsg{Code: tea.KeyEnter})49+ collapsed := next.(createScreen)50+ if collapsed.create.expanded[parentID] {51+ t.Fatal("expected parent to collapse")52+ }53+}54+55+func TestCreateScreenGenerateButtonProducesAction(t *testing.T) {56+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/5x5"}}, 80)57+ state.focus = createFocusGenerate58+ screen := createScreen{create: state}59+60+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})61+ if _, ok := action.(createGenerateAction); !ok {62+ t.Fatalf("action = %T, want createGenerateAction", action)63+ }64+}65+66+func TestCreateScreenGenerateButtonDisabledWithoutSelection(t *testing.T) {67+ state := newCreateState(config.CreateConfig{Elo: 1200}, 80)68+ state.focus = createFocusGenerate69+ screen := createScreen{create: state}70+71+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})72+ if action != nil {73+ t.Fatalf("action = %T, want nil", action)74+ }75+}76+77+func TestCreateScreenMouseClickFocusesFields(t *testing.T) {78+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/5x5"}}, 120)79+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)80+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)81+ metrics := createMetrics(screen.create, contentWidth, contentHeight)82+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))83+84+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{85+ X: contentX + metrics.Settings.Seed.X + 8,86+ Y: contentY + metrics.Settings.Seed.Y + 1,87+ }, contentWidth, contentHeight)88+ if !handled {89+ t.Fatal("expected seed field click to be handled")90+ }91+ screen = next.(createScreen)92+ if screen.create.focus != createFocusSeed {93+ t.Fatalf("focus = %v, want createFocusSeed", screen.create.focus)94+ }95+96+ metrics = createMetrics(screen.create, contentWidth, contentHeight)97+ next, _, _, handled = screen.handleMouseClick(tea.MouseClickMsg{98+ X: contentX + metrics.Settings.Elo.X + 7,99+ Y: contentY + metrics.Settings.Elo.Y + 1,100+ }, contentWidth, contentHeight)101+ if !handled {102+ t.Fatal("expected elo field click to be handled")103+ }104+ screen = next.(createScreen)105+ if screen.create.focus != createFocusElo {106+ t.Fatalf("focus = %v, want createFocusElo", screen.create.focus)107+ }108+}109+110+func TestCreateScreenMouseClickDisabledSeedDoesNotFocusSeed(t *testing.T) {111+ state := newCreateState(config.CreateConfig{112+ SelectedLeafIDs: []string{"nonogram/5x5", "netwalk/5x5"},113+ }, 120)114+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)115+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)116+ metrics := createMetrics(screen.create, contentWidth, contentHeight)117+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))118+119+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{120+ X: contentX + metrics.Settings.Seed.X + 8,121+ Y: contentY + metrics.Settings.Seed.Y + 1,122+ }, contentWidth, contentHeight)123+ if !handled {124+ t.Fatal("expected disabled seed field click to be handled")125+ }126+ got := next.(createScreen)127+ if got.create.focus == createFocusSeed {128+ t.Fatal("disabled seed click should not focus the seed input")129+ }130+}131+132+func TestCreateScreenMouseClickGenerateButtonProducesAction(t *testing.T) {133+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/5x5"}}, 120)134+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)135+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)136+ metrics := createMetrics(screen.create, contentWidth, contentHeight)137+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))138+139+ _, _, action, handled := screen.handleMouseClick(tea.MouseClickMsg{140+ X: contentX + metrics.SummaryButton.X + 1,141+ Y: contentY + metrics.SummaryButton.Y,142+ }, contentWidth, contentHeight)143+ if !handled {144+ t.Fatal("expected generate button click to be handled")145+ }146+ if _, ok := action.(createGenerateAction); !ok {147+ t.Fatalf("action = %T, want createGenerateAction", action)148+ }149+}150+151+func TestCreateScreenMouseClickTreeRowsSelectsAndToggles(t *testing.T) {152+ state := newCreateState(config.CreateConfig{}, 120)153+ parentID := state.visibleNodes()[0].node.id154+ state.expanded[parentID] = true155+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)156+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)157+ metrics := createMetrics(screen.create, contentWidth, contentHeight)158+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))159+160+ leafIndex := -1161+ for i, item := range screen.create.visibleNodes() {162+ if item.node.leaf != nil {163+ leafIndex = i164+ break165+ }166+ }167+ if leafIndex < 0 {168+ t.Fatal("test setup expected a visible leaf")169+ }170+ row := leafIndex - metrics.TreeStart171+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{172+ X: contentX + metrics.Tree.X + 4,173+ Y: contentY + metrics.Tree.Y + 2 + row,174+ }, contentWidth, contentHeight)175+ if !handled {176+ t.Fatal("expected tree leaf click to be handled")177+ }178+ got := next.(createScreen)179+ if got.create.focus != createFocusTree {180+ t.Fatalf("focus = %v, want createFocusTree", got.create.focus)181+ }182+ if got.create.cursor != leafIndex {183+ t.Fatalf("cursor = %d, want %d", got.create.cursor, leafIndex)184+ }185+ if got.create.selectedCount() != 1 {186+ t.Fatalf("selected count = %d, want 1", got.create.selectedCount())187+ }188+}189+190+func TestCreateScreenMouseClickTreeParentExpands(t *testing.T) {191+ state := newCreateState(config.CreateConfig{}, 120)192+ parentID := state.visibleNodes()[0].node.id193+ screen := createScreen{create: state}.Resize(120, 36).(createScreen)194+ contentWidth, contentHeight := createContentBounds(screen.width, screen.height)195+ metrics := createMetrics(screen.create, contentWidth, contentHeight)196+ _, _, contentX, contentY := createPanelContentOrigin(screen.width, screen.height, createBody(screen.create, screen.width, screen.height))197+198+ next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{199+ X: contentX + metrics.Tree.X + 4,200+ Y: contentY + metrics.Tree.Y + 2,201+ }, contentWidth, contentHeight)202+ if !handled {203+ t.Fatal("expected tree parent click to be handled")204+ }205+ got := next.(createScreen)206+ if !got.create.expanded[parentID] {207+ t.Fatal("expected parent click to expand the tree row")208+ }209+}210+211+func TestCreateScreenRenderedParentRowsIncludeRollupCounts(t *testing.T) {212+ state := newCreateState(config.CreateConfig{}, 80)213+ nonogram := requireCreateNode(t, state.tree, "Nonogram")214+ state.checked[nonogram.children[0].leaf.id] = true215+216+ line := renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)217+ if !strings.Contains(line, "Nonogram") || !strings.Contains(line, "1/4") {218+ t.Fatalf("rendered line = %q, want Nonogram with 1/4 rollup", line)219+ }220+}221+222+func TestCreateScreenRenderedParentRowsSignalSelectionState(t *testing.T) {223+ state := newCreateState(config.CreateConfig{}, 80)224+ nonogram := requireCreateNode(t, state.tree, "Nonogram")225+226+ line := renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)227+ if !strings.Contains(line, "[ ] Nonogram") {228+ t.Fatalf("unchecked parent line = %q, want unchecked marker", line)229+ }230+231+ state.checked[nonogram.children[0].leaf.id] = true232+ line = renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)233+ if !strings.Contains(line, "[-] Nonogram") {234+ t.Fatalf("partial parent line = %q, want partial marker", line)235+ }236+237+ state.toggleDescendantLeaves(nonogram)238+ line = renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)239+ if !strings.Contains(line, "[x] Nonogram") {240+ t.Fatalf("selected parent line = %q, want checked marker", line)241+ }242+}243+244+func TestCreateScreenEloFieldDoesNotRenderPromptMarker(t *testing.T) {245+ state := newCreateState(config.CreateConfig{Elo: 1200}, 80)246+ state.focus = createFocusElo247+ screen, _, _ := (createScreen{create: state}).syncFocus()248+ state = screen.(createScreen).create249+250+ settings := ansi.Strip(createSettingsPanel(state, 80))251+ if strings.Contains(settings, "> 1200") || strings.Contains(settings, ">1200") {252+ t.Fatalf("settings rendered Elo prompt marker: %q", settings)253+ }254+ if got := state.eloInput.Value(); got != "1200" {255+ t.Fatalf("elo input value = %q, want 1200", got)256+ }257+}
M app/screen_menu.go +1 -3
M app/screens_test.go +4 -4
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 +1 -1
M app/view_test.go +17 -0
M cmd/config_test.go +4 -0
M cmd/new.go +60 -13
@@ -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,12 +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+ mode, err := resolve.ModeEntry(entry, modeArg)7277 if err != nil {7378 return err7479 }7580 76- g, err := spawnFromMode(spawner, seed)81+ targetElo, err := difficultyFlag()82+ if err != nil {83+ return err84+ }85+86+ g, report, err := spawnFromMode(mode, seed, targetElo)7787 if err != nil {7888 return fmt.Errorf("failed to spawn game: %w", err)7989 }
@@ -87,7 +97,8 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
8797 name := sessionflow.GenerateUniqueName(s)8898 g = g.SetTitle(name)8999 90- rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle, store.NormalRunMetadata())100+ meta := difficultyMetadataFromReport(report)101+ rec, err := sessionflow.CreateRecordWithDifficulty(s, g, name, entry.Definition.Name, mode.Definition.Title, store.NormalRunMetadata(), meta)91102 if err != nil {92103 return err93104 }
@@ -95,16 +106,52 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
95106 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)96107 }97108 98-func spawnFromMode(spawner game.Spawner, seed string) (game.Gamer, error) {109+func spawnFromMode(mode registry.ModeEntry, seed string, targetElo *difficulty.Elo) (game.Gamer, difficulty.Report, error) {110+ if targetElo != nil {111+ if mode.Elo == nil {112+ return nil, difficulty.Report{}, fmt.Errorf("mode does not support Elo difficulty")113+ }114+ g, report, err := mode.Elo.SpawnElo(seed, *targetElo)115+ if err != nil {116+ return nil, difficulty.Report{}, err117+ }118+ return g, report, nil119+ }120+99121 if seed == "" {100- return spawner.Spawn()122+ g, err := mode.Spawner.Spawn()123+ return g, difficulty.Report{}, err101124 }102125 103- seeded, ok := spawner.(game.SeededSpawner)104- if !ok {105- return nil, fmt.Errorf("mode does not support deterministic spawning")126+ if mode.Seeded == nil {127+ return nil, difficulty.Report{}, fmt.Errorf("mode does not support deterministic spawning")128+ }129+ g, err := mode.Seeded.SpawnSeeded(resolve.RNGFromString(seed))130+ return g, difficulty.Report{}, err131+}132+133+func difficultyFlag() (*difficulty.Elo, error) {134+ if !flagDifficultySet {135+ return nil, nil136+ }137+ elo := difficulty.Elo(flagDifficulty)138+ if err := difficulty.ValidateElo(elo); err != nil {139+ return nil, err140+ }141+ return &elo, nil142+}143+144+func difficultyMetadataFromReport(report difficulty.Report) sessionflow.DifficultyMetadata {145+ if report.Confidence == "" {146+ return sessionflow.DifficultyMetadata{}147+ }148+ target := report.TargetElo149+ actual := report.ActualElo150+ return sessionflow.DifficultyMetadata{151+ TargetElo: &target,152+ ActualElo: &actual,153+ Confidence: report.Confidence,106154 }107- return seeded.SpawnSeeded(resolve.RNGFromString(seed))108155 }109156 110157 // launchSeededGame uses an arbitrary seed string to deterministically select
M cmd/new_export.go +57 -14
@@ -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"
@@ -23,11 +24,13 @@ var exportNow = time.Now
2324 2425 type exportModeEntry struct {2526 spawner game.Spawner27+ elo game.EloSpawner2628 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,17 +93,18 @@ 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+ mode, err := resolve.ModeEntry(entry, modeArg)8997 if err != nil {9098 return nil, "", err9199 }92- return []exportModeEntry{{spawner: spawner, mode: modeTitle}}, modeTitle, nil100+ return []exportModeEntry{{spawner: mode.Spawner, elo: mode.Elo, mode: mode.Definition.Title}}, mode.Definition.Title, nil93101 }94102 95103 entries := make([]exportModeEntry, 0, len(entry.Modes))96104 for _, mode := range entry.Modes {97105 entries = append(entries, exportModeEntry{98106 spawner: mode.Spawner,107+ elo: mode.Elo,99108 mode: mode.Definition.Title,100109 })101110 }
@@ -111,6 +120,7 @@ func buildExportRecords(
111120 entries []exportModeEntry,112121 count int,113122 seed string,123+ targetElo *difficulty.Elo,114124 generatedAt time.Time,115125 ) ([]pdfexport.JSONLRecord, error) {116126 var rng *rand.Rand
@@ -137,7 +147,8 @@ func buildExportRecords(
137147 entry = entries[modeIndex]138148 }139149 140- puzzle, err := spawnExportPuzzle(entry.spawner, rng)150+ eloSeed := scopedExportEloSeed(seed, generatedAt, gameType, entry.mode, i+1)151+ puzzle, report, err := spawnExportPuzzle(entry, rng, eloSeed, targetElo)141152 if err != nil {142153 return nil, fmt.Errorf("generate puzzle %d: %w", i+1, err)143154 }
@@ -161,11 +172,14 @@ func buildExportRecords(
161172 Seed: seed,162173 },163174 Puzzle: pdfexport.JSONLPuzzle{164- Index: i + 1,165- Name: namegen.GenerateSeeded(nameRNG),166- Game: gameType,167- Mode: entry.mode,168- Save: json.RawMessage(save),175+ Index: i + 1,176+ Name: namegen.GenerateSeeded(nameRNG),177+ Game: gameType,178+ Mode: entry.mode,179+ TargetDifficultyElo: intPtrFromEloReport(report.TargetElo, report.Confidence),180+ ActualDifficultyElo: intPtrFromEloReport(report.ActualElo, report.Confidence),181+ DifficultyConfidence: string(report.Confidence),182+ Save: json.RawMessage(save),169183 },170184 })171185 }
@@ -173,16 +187,45 @@ func buildExportRecords(
173187 return records, nil174188 }175189 176-func spawnExportPuzzle(spawner game.Spawner, rng *rand.Rand) (game.Gamer, error) {190+func scopedExportEloSeed(seed string, generatedAt time.Time, gameType, mode string, index int) string {191+ base := strings.TrimSpace(seed)192+ if base == "" {193+ base = generatedAt.Format(time.RFC3339Nano)194+ }195+ return fmt.Sprintf("export:%s:%s:%s:%d", base, gameType, mode, index)196+}197+198+func spawnExportPuzzle(entry exportModeEntry, rng *rand.Rand, seed string, targetElo *difficulty.Elo) (game.Gamer, difficulty.Report, error) {199+ if targetElo != nil {200+ if entry.elo == nil {201+ return nil, difficulty.Report{}, fmt.Errorf("mode does not support Elo difficulty")202+ }203+ g, report, err := entry.elo.SpawnElo(seed, *targetElo)204+ if err != nil {205+ return nil, difficulty.Report{}, err206+ }207+ return g, report, nil208+ }209+177210 if rng == nil {178- return spawner.Spawn()211+ g, err := entry.spawner.Spawn()212+ return g, difficulty.Report{}, err179213 }180214 181- seeded, ok := spawner.(game.SeededSpawner)215+ seeded, ok := entry.spawner.(game.SeededSpawner)182216 if !ok {183- return nil, fmt.Errorf("mode does not support deterministic spawning")217+ return nil, difficulty.Report{}, fmt.Errorf("mode does not support deterministic spawning")218+ }219+ g, err := seeded.SpawnSeeded(rng)220+ return g, difficulty.Report{}, err221+}222+223+func intPtrFromEloReport(elo difficulty.Elo, confidence difficulty.Confidence) *int {224+ if confidence == "" {225+ return nil184226 }185- return seeded.SpawnSeeded(rng)227+ v := int(elo)228+ return &v186229 }187230 188231 func writeExportJSONL(cmd *cobra.Command, path string, records []pdfexport.JSONLRecord) error {
M cmd/new_export_test.go +6 -0
M config/config.go +7 -0
M config/config_test.go +26 -0
A difficulty/difficulty.go +50 -0
A difficulty/difficulty_test.go +55 -0
M export/pack/render.go +4 -0
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 +51 -6
@@ -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"
@@ -61,6 +62,7 @@ type ModeCatalog struct {
6162 ModeID puzzle.ModeID6263 ModeTitle string6364 Seeded bool65+ PresetElo *int6466 }6567 6668 type modeSelection struct {
@@ -71,6 +73,8 @@ type modeSelection struct {
7173 count int7274 spawner game.Spawner7375 seededSpawner game.SeededSpawner76+ eloSpawner game.EloSpawner77+ targetElo *difficulty.Elo7478 }7579 7680 type runPlan struct {
@@ -121,6 +125,7 @@ func ExportCatalog() []GameCatalog {
121125 ModeID: mode.Definition.ID,122126 ModeTitle: mode.Definition.Title,123127 Seeded: mode.Seeded != nil,128+ PresetElo: intPtrFromElo(mode.Definition.PresetElo),124129 })125130 }126131
@@ -338,6 +343,8 @@ func buildRunPlan(spec Spec) (runPlan, error) {
338343 count: count,339344 spawner: mode.Spawner,340345 seededSpawner: mode.Seeded,346+ eloSpawner: mode.Elo,347+ targetElo: cloneElo(mode.Definition.PresetElo),341348 })342349 }343350 }
@@ -405,13 +412,16 @@ func generateRecords(
405412 }406413 407414 records := make([]pdfexport.JSONLRecord, 0, totalCount)415+ recordIndex := 0408416 for _, selection := range selections {409417 for i := 0; i < selection.count; i++ {410418 if err := ctx.Err(); err != nil {411419 return nil, err412420 }421+ recordIndex++413422 414- saveData, err := spawnSaveData(ctx, selection, rng)423+ eloSeed := scopedPackEloSeed(seed, generatedAt, selection.gameType, selection.modeTitle, recordIndex)424+ generated, err := spawnSaveData(ctx, selection, rng, eloSeed)415425 if err != nil {416426 return nil, fmt.Errorf("generate %s / %s puzzle %d: %w", selection.gameType, selection.modeTitle, i+1, err)417427 }
@@ -427,11 +437,14 @@ func generateRecords(
427437 Seed: seed,428438 },429439 Puzzle: pdfexport.JSONLPuzzle{430- Index: len(records) + 1,431- Name: namegen.GenerateSeeded(nameRNG),432- Game: selection.gameType,433- Mode: selection.modeTitle,434- Save: saveData,440+ Index: len(records) + 1,441+ Name: namegen.GenerateSeeded(nameRNG),442+ Game: selection.gameType,443+ Mode: selection.modeTitle,444+ TargetDifficultyElo: intPtrFromReportElo(generated.Report.TargetElo, generated.Report.Confidence),445+ ActualDifficultyElo: intPtrFromReportElo(generated.Report.ActualElo, generated.Report.Confidence),446+ DifficultyConfidence: string(generated.Report.Confidence),447+ Save: generated.SaveData,435448 },436449 })437450 }
@@ -439,3 +452,35 @@ func generateRecords(
439452 440453 return records, nil441454 }455+456+func intPtrFromElo(elo *difficulty.Elo) *int {457+ if elo == nil {458+ return nil459+ }460+ v := int(*elo)461+ return &v462+}463+464+func cloneElo(elo *difficulty.Elo) *difficulty.Elo {465+ if elo == nil {466+ return nil467+ }468+ v := *elo469+ return &v470+}471+472+func intPtrFromReportElo(elo difficulty.Elo, confidence difficulty.Confidence) *int {473+ if confidence == "" {474+ return nil475+ }476+ v := int(elo)477+ return &v478+}479+480+func scopedPackEloSeed(seed string, generatedAt time.Time, gameType, mode string, index int) string {481+ base := strings.TrimSpace(seed)482+ if base == "" {483+ base = generatedAt.Format(time.RFC3339Nano)484+ }485+ return fmt.Sprintf("packexport:%s:%s:%s:%d", base, gameType, mode, index)486+}
M export/pdf/jsonl.go +57 -12
@@ -9,6 +9,8 @@ import (
99 "path/filepath"1010 "slices"1111 "strings"12+13+ "github.com/FelineStateMachine/puzzletea/difficulty"1214 )1315 1416 const ExportSchemaV1 = "puzzletea.export.v1"
@@ -29,11 +31,14 @@ type JSONLPackMeta struct {
2931 }3032 3133 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"`34+ Index int `json:"index"`35+ Name string `json:"name"`36+ Game string `json:"game"`37+ Mode string `json:"mode"`38+ TargetDifficultyElo *int `json:"target_difficulty_elo,omitempty"`39+ ActualDifficultyElo *int `json:"actual_difficulty_elo,omitempty"`40+ DifficultyConfidence string `json:"difficulty_confidence,omitempty"`41+ Save json.RawMessage `json:"save"`3742 }3843 3944 func ParseJSONLFiles(paths []string) ([]PackDocument, error) {
@@ -117,14 +122,17 @@ func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error
117122 }118123 119124 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...),125+ SourcePath: path,126+ SourceFileName: filepath.Base(path),127+ Category: category,128+ ModeSelection: mode,129+ Name: record.Puzzle.Name,130+ Index: record.Puzzle.Index,131+ SaveData: append([]byte(nil), record.Puzzle.Save...),132+ TargetDifficultyElo: cloneInt(record.Puzzle.TargetDifficultyElo),133+ ActualDifficultyElo: cloneInt(record.Puzzle.ActualDifficultyElo),127134 }135+ annotateJSONLDifficulty(&p, record.Puzzle)128136 129137 adapter, ok := LookupPrintAdapter(category)130138 if !ok {
@@ -178,3 +186,40 @@ func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error
178186 doc.Puzzles = puzzles179187 return doc, nil180188 }189+190+func annotateJSONLDifficulty(p *Puzzle, puzzle JSONLPuzzle) {191+ if puzzle.ActualDifficultyElo != nil {192+ elo := difficulty.Elo(*puzzle.ActualDifficultyElo)193+ p.DifficultyScore = difficulty.Score01(elo)194+ p.DifficultyConfidence = parseDifficultyConfidence(puzzle.DifficultyConfidence)195+ p.DifficultySource = "actual-elo"196+ return197+ }198+ if puzzle.TargetDifficultyElo != nil {199+ elo := difficulty.Elo(*puzzle.TargetDifficultyElo)200+ p.DifficultyScore = difficulty.Score01(elo)201+ p.DifficultyConfidence = parseDifficultyConfidence(puzzle.DifficultyConfidence)202+ p.DifficultySource = "target-elo"203+ }204+}205+206+func parseDifficultyConfidence(raw string) DifficultyConfidence {207+ switch difficulty.Confidence(strings.ToLower(strings.TrimSpace(raw))) {208+ case difficulty.ConfidenceLow:209+ return DifficultyConfidenceLow210+ case difficulty.ConfidenceMedium:211+ return DifficultyConfidenceMedium212+ case difficulty.ConfidenceHigh:213+ return DifficultyConfidenceHigh214+ default:215+ return DifficultyConfidenceMedium216+ }217+}218+219+func cloneInt(value *int) *int {220+ if value == nil {221+ return nil222+ }223+ v := *value224+ return &v225+}
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 +22 -0
M games/fillomino/README.md +5 -0
A games/fillomino/elo.go +116 -0
@@ -0,0 +1,116 @@
1+package fillomino2+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+func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {17+ if err := difficulty.ValidateElo(elo); err != nil {18+ return nil, difficulty.Report{}, err19+ }20+21+ mode := fillominoModeForElo(elo)22+ puzzle, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, fillominoEloRNG(seed, elo))23+ if err != nil {24+ return nil, difficulty.Report{}, err25+ }26+ g, err := New(mode, puzzle)27+ if err != nil {28+ return nil, difficulty.Report{}, err29+ }30+ return g, fillominoDifficultyReport(elo, mode, puzzle), nil31+}32+33+func fillominoModeForElo(elo difficulty.Elo) Mode {34+ score := difficulty.Score01(elo)35+ size := 5 + int(math.Round(score*7))36+ maxRegion := 5 + int(math.Round(score*4))37+ givenRatio := 0.70 - score*0.1838+ return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted Fillomino puzzle.", size, maxRegion, givenRatio)39+}40+41+func fillominoEloRNG(seed string, elo difficulty.Elo) *rand.Rand {42+ sum := sha256.Sum256([]byte("fillomino\x00" + seed + "\x00" + strconv.Itoa(int(elo))))43+ return rand.New(rand.NewPCG(44+ binary.LittleEndian.Uint64(sum[0:8]),45+ binary.LittleEndian.Uint64(sum[8:16]),46+ ))47+}48+49+func fillominoDifficultyReport(target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {50+ solutions := countSolutions(puzzle.Givens, mode.MaxRegion, 2)51+ metrics := fillominoDifficultyMetrics(mode, puzzle, solutions)52+ confidence := difficulty.ConfidenceHigh53+ if solutions != 1 {54+ confidence = difficulty.ConfidenceLow55+ }56+ return difficulty.Report{57+ TargetElo: target,58+ ActualElo: fillominoActualElo(metrics),59+ Confidence: confidence,60+ Metrics: metrics,61+ }62+}63+64+func fillominoDifficultyMetrics(mode Mode, puzzle Puzzle, solutions int) difficulty.Metrics {65+ cells := puzzle.Width * puzzle.Height66+ givens := 067+ maxRegionValue := 068+ for _, row := range puzzle.Givens {69+ for _, value := range row {70+ if value == 0 {71+ continue72+ }73+ givens++74+ if value > maxRegionValue {75+ maxRegionValue = value76+ }77+ }78+ }79+ return difficulty.Metrics{80+ "width": float64(puzzle.Width),81+ "height": float64(puzzle.Height),82+ "cells": float64(cells),83+ "given_count": float64(givens),84+ "unknown_count": float64(cells - givens),85+ "given_ratio": fillominoRatio(givens, cells),86+ "target_ratio": mode.GivenRatio,87+ "max_region": float64(mode.MaxRegion),88+ "max_given_region": float64(maxRegionValue),89+ "solution_count": float64(solutions),90+ }91+}92+93+func fillominoActualElo(metrics difficulty.Metrics) difficulty.Elo {94+ score := 0.40*fillominoNormalize(metrics["cells"], 25, 144) +95+ 0.25*(1-fillominoNormalize(metrics["given_ratio"], 0.52, 0.70)) +96+ 0.20*fillominoNormalize(metrics["max_region"], 5, 9) +97+ 0.15*fillominoNormalize(metrics["unknown_count"], 8, 70)98+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))99+}100+101+func fillominoNormalize(value, low, high float64) float64 {102+ if high <= low || value <= low {103+ return 0104+ }105+ if value >= high {106+ return 1107+ }108+ return (value - low) / (high - low)109+}110+111+func fillominoRatio(numerator, denominator int) float64 {112+ if denominator == 0 {113+ return 0114+ }115+ return float64(numerator) / float64(denominator)116+}
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 +129 -0
@@ -0,0 +1,129 @@
1+package hashiwokakero2+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 = HashiMode{}15+16+func (h HashiMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {17+ if err := difficulty.ValidateElo(elo); err != nil {18+ return nil, difficulty.Report{}, err19+ }20+21+ mode := hashiModeForElo(elo)22+ puzzle, err := GeneratePuzzleSeeded(mode, hashiEloRNG(seed, elo))23+ if err != nil {24+ return nil, difficulty.Report{}, err25+ }26+ return New(mode, puzzle), hashiDifficultyReport(elo, mode, puzzle), nil27+}28+29+func hashiModeForElo(elo difficulty.Elo) HashiMode {30+ score := difficulty.Score01(elo)31+ size := 7 + 2*int(math.Round(score*3))32+ density := 0.17 + score*0.2333+ target := int(math.Round(float64(size*size) * density))34+ minIslands := max(3, target-2)35+ maxIslands := min(size*size, target+2)36+37+ return NewMode(38+ "Elo "+strconv.Itoa(int(elo)),39+ "Elo-targeted Hashiwokakero puzzle.",40+ size,41+ size,42+ minIslands,43+ maxIslands,44+ )45+}46+47+func hashiEloRNG(seed string, elo difficulty.Elo) *rand.Rand {48+ gameID := string(Definition.ID)49+ if gameID == "" {50+ gameID = "hashiwokakero"51+ }52+ sum := sha256.Sum256([]byte(gameID + "\x00" + seed + "\x00" + strconv.Itoa(int(elo))))53+ return rand.New(rand.NewPCG(54+ binary.LittleEndian.Uint64(sum[0:8]),55+ binary.LittleEndian.Uint64(sum[8:16]),56+ ))57+}58+59+func hashiDifficultyReport(target difficulty.Elo, mode HashiMode, puzzle Puzzle) difficulty.Report {60+ metrics := hashiDifficultyMetrics(mode, puzzle)61+ return difficulty.Report{62+ TargetElo: target,63+ ActualElo: hashiActualElo(metrics),64+ Confidence: difficulty.ConfidenceMedium,65+ Metrics: metrics,66+ }67+}68+69+func hashiDifficultyMetrics(mode HashiMode, puzzle Puzzle) difficulty.Metrics {70+ cells := puzzle.Width * puzzle.Height71+ islands := len(puzzle.Islands)72+ totalRequired := 073+ maxRequired := 074+ highRequired := 075+ for _, island := range puzzle.Islands {76+ totalRequired += island.Required77+ if island.Required > maxRequired {78+ maxRequired = island.Required79+ }80+ if island.Required >= 5 {81+ highRequired++82+ }83+ }84+85+ connectablePairs := len(connectableIslandPairs(&puzzle))86+ return difficulty.Metrics{87+ "width": float64(puzzle.Width),88+ "height": float64(puzzle.Height),89+ "cells": float64(cells),90+ "islands": float64(islands),91+ "island_density": hashiRatio(islands, cells),92+ "target_minlands": float64(mode.MinIslands),93+ "target_maxlands": float64(mode.MaxIslands),94+ "total_required": float64(totalRequired),95+ "avg_required": hashiRatio(totalRequired, islands),96+ "max_required": float64(maxRequired),97+ "high_required": float64(highRequired),98+ "connectable_pairs": float64(connectablePairs),99+ "pair_density": hashiRatio(connectablePairs, max(1, islands)),100+ }101+}102+103+func hashiActualElo(metrics difficulty.Metrics) difficulty.Elo {104+ score := 0.24*normalizeHashi(metrics["width"], 7, 13) +105+ 0.20*normalizeHashi(metrics["island_density"], 0.14, 0.42) +106+ 0.17*normalizeHashi(metrics["total_required"]/metrics["cells"], 0.16, 0.58) +107+ 0.15*normalizeHashi(metrics["avg_required"], 1.6, 5.0) +108+ 0.13*normalizeHashi(metrics["pair_density"], 1.0, 3.5) +109+ 0.11*normalizeHashi(metrics["high_required"], 0, metrics["islands"]*0.45)110+111+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))112+}113+114+func normalizeHashi(value, low, high float64) float64 {115+ if high <= low || value <= low {116+ return 0117+ }118+ if value >= high {119+ return 1120+ }121+ return (value - low) / (high - low)122+}123+124+func hashiRatio(numerator, denominator int) float64 {125+ if denominator == 0 {126+ return 0127+ }128+ return float64(numerator) / float64(denominator)129+}
A games/hashiwokakero/elo_test.go +119 -0
@@ -0,0 +1,119 @@
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 TestModeDefinitionsIncludePresetElo(t *testing.T) {108+ if len(ModeDefinitions) != len(Modes) {109+ t.Fatalf("ModeDefinitions length = %d, want %d", len(ModeDefinitions), len(Modes))110+ }111+ for i, def := range ModeDefinitions {112+ if def.PresetElo == nil {113+ t.Fatalf("ModeDefinitions[%d].PresetElo is nil", i)114+ }115+ if err := difficulty.ValidateElo(*def.PresetElo); err != nil {116+ t.Fatalf("ModeDefinitions[%d].PresetElo = %d is invalid: %v", i, *def.PresetElo, err)117+ }118+ }119+}
M games/hashiwokakero/gamemode.go +19 -1
M games/hitori/README.md +5 -0
A games/hitori/elo.go +147 -0
@@ -0,0 +1,147 @@
1+package hitori2+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+func (h HitoriMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {15+ if err := difficulty.ValidateElo(elo); err != nil {16+ return nil, difficulty.Report{}, err17+ }18+19+ mode := hitoriModeForElo(h, elo)20+ puzzle, err := GenerateSeeded(mode.Size, mode.BlackRatio, hitoriEloRNG(seed, elo))21+ if err != nil {22+ return nil, difficulty.Report{}, err23+ }24+25+ gamer, err := New(mode, puzzle)26+ if err != nil {27+ return nil, difficulty.Report{}, err28+ }29+ return gamer, hitoriDifficultyReport(elo, mode, puzzle), nil30+}31+32+func hitoriModeForElo(base HitoriMode, elo difficulty.Elo) HitoriMode {33+ score := difficulty.Score01(elo)34+ size := 5 + int(math.Round(score*7))35+ blackRatio := 0.32 - score*0.0436+37+ mode := base38+ mode.BaseMode = game.NewBaseMode(39+ "Elo "+strconv.Itoa(int(elo)),40+ "Elo-targeted Hitori puzzle.",41+ )42+ mode.Size = size43+ mode.BlackRatio = blackRatio44+ return mode45+}46+47+func hitoriEloRNG(seed string, elo difficulty.Elo) *rand.Rand {48+ sum := sha256.Sum256([]byte("hitori\x00" + seed + "\x00" + strconv.Itoa(int(elo))))49+ return rand.New(rand.NewPCG(50+ binary.LittleEndian.Uint64(sum[0:8]),51+ binary.LittleEndian.Uint64(sum[8:16]),52+ ))53+}54+55+func hitoriDifficultyReport(target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {56+ metrics := hitoriDifficultyMetrics(mode, puzzle)57+ return difficulty.Report{58+ TargetElo: target,59+ ActualElo: hitoriActualElo(metrics),60+ Confidence: difficulty.ConfidenceMedium,61+ Metrics: metrics,62+ }63+}64+65+func hitoriDifficultyMetrics(mode HitoriMode, puzzle grid) difficulty.Metrics {66+ size := len(puzzle)67+ cells := size * size68+ duplicateCells, duplicateGroups := hitoriDuplicatePressure(puzzle)69+70+ return difficulty.Metrics{71+ "size": float64(size),72+ "cells": float64(cells),73+ "target_black_pct": mode.BlackRatio,74+ "duplicate_cells": float64(duplicateCells),75+ "duplicate_groups": float64(duplicateGroups),76+ "duplicate_pct": hitoriRatio(duplicateCells, cells),77+ "solution_count": float64(countPuzzleSolutions(puzzle, size, 2)),78+ }79+}80+81+func hitoriDuplicatePressure(puzzle grid) (int, int) {82+ duplicateCells := 083+ duplicateGroups := 084+85+ for _, row := range puzzle {86+ cells, groups := hitoriDuplicateLinePressure(row)87+ duplicateCells += cells88+ duplicateGroups += groups89+ }90+91+ size := len(puzzle)92+ for x := range size {93+ col := make([]rune, size)94+ for y := range size {95+ col[y] = puzzle[y][x]96+ }97+ cells, groups := hitoriDuplicateLinePressure(col)98+ duplicateCells += cells99+ duplicateGroups += groups100+ }101+102+ return duplicateCells, duplicateGroups103+}104+105+func hitoriDuplicateLinePressure(line []rune) (int, int) {106+ counts := make(map[rune]int, len(line))107+ for _, value := range line {108+ counts[value]++109+ }110+111+ cells := 0112+ groups := 0113+ for _, count := range counts {114+ if count < 2 {115+ continue116+ }117+ cells += count118+ groups++119+ }120+ return cells, groups121+}122+123+func hitoriActualElo(metrics difficulty.Metrics) difficulty.Elo {124+ score := 0.50*hitoriNormalize(metrics["size"], 5, 12) +125+ 0.28*hitoriNormalize(metrics["duplicate_pct"], 0.20, 1.20) +126+ 0.17*hitoriNormalize(metrics["target_black_pct"], 0.28, 0.34) +127+ 0.05*hitoriNormalize(metrics["duplicate_groups"], 4, 36)128+129+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))130+}131+132+func hitoriNormalize(value, low, high float64) float64 {133+ if high <= low || value <= low {134+ return 0135+ }136+ if value >= high {137+ return 1138+ }139+ return (value - low) / (high - low)140+}141+142+func hitoriRatio(numerator, denominator int) float64 {143+ if denominator == 0 {144+ return 0145+ }146+ return float64(numerator) / float64(denominator)147+}
A games/hitori/elo_test.go +92 -0
@@ -0,0 +1,92 @@
1+package hitori2+3+import (4+ "reflect"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/difficulty"8+)9+10+func TestSpawnEloRejectsInvalidElo(t *testing.T) {11+ mode := NewMode("Test", "Test mode.", 5, 0.32)12+13+ gamer, report, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1)14+ if err == nil {15+ t.Fatal("SpawnElo returned nil error for invalid Elo")16+ }17+ if gamer != nil {18+ t.Fatalf("SpawnElo gamer = %#v, want nil", gamer)19+ }20+ if !reflect.DeepEqual(report, difficulty.Report{}) {21+ t.Fatalf("SpawnElo report = %#v, want zero report", report)22+ }23+}24+25+func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {26+ mode := NewMode("Test", "Test mode.", 5, 0.32)27+ target := difficulty.Elo(1500)28+29+ gamerA, reportA, err := mode.SpawnElo("same-seed", target)30+ if err != nil {31+ t.Fatalf("first SpawnElo returned error: %v", err)32+ }33+ gamerB, reportB, err := mode.SpawnElo("same-seed", target)34+ if err != nil {35+ t.Fatalf("second SpawnElo returned error: %v", err)36+ }37+38+ saveA, err := gamerA.GetSave()39+ if err != nil {40+ t.Fatalf("first GetSave returned error: %v", err)41+ }42+ saveB, err := gamerB.GetSave()43+ if err != nil {44+ t.Fatalf("second GetSave returned error: %v", err)45+ }46+ if string(saveA) != string(saveB) {47+ t.Fatalf("SpawnElo saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)48+ }49+ if !reflect.DeepEqual(reportA, reportB) {50+ t.Fatalf("SpawnElo reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)51+ }52+}53+54+func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {55+ mode := NewMode("Test", "Test mode.", 5, 0.32)56+ target := difficulty.Elo(2200)57+58+ gamer, report, err := mode.SpawnElo("report-fields", target)59+ if err != nil {60+ t.Fatalf("SpawnElo returned error: %v", err)61+ }62+ if gamer == nil {63+ t.Fatal("SpawnElo returned nil gamer")64+ }65+ if report.TargetElo != target {66+ t.Fatalf("TargetElo = %d, want %d", report.TargetElo, target)67+ }68+ if report.ActualElo < difficulty.MinElo || report.ActualElo > difficulty.SoftCapElo {69+ t.Fatalf("ActualElo = %d, want in supported range", report.ActualElo)70+ }71+ if report.Confidence != difficulty.ConfidenceMedium {72+ t.Fatalf("Confidence = %q, want %q", report.Confidence, difficulty.ConfidenceMedium)73+ }74+75+ requiredMetrics := []string{76+ "size",77+ "cells",78+ "target_black_pct",79+ "duplicate_cells",80+ "duplicate_groups",81+ "duplicate_pct",82+ "solution_count",83+ }84+ for _, name := range requiredMetrics {85+ if _, ok := report.Metrics[name]; !ok {86+ t.Fatalf("report metric %q missing from %#v", name, report.Metrics)87+ }88+ }89+ if report.Metrics["solution_count"] != 1 {90+ t.Fatalf("solution_count = %v, want 1", report.Metrics["solution_count"])91+ }92+}
M games/hitori/gamemode.go +19 -1
M games/hitori/generator.go +4 -0
M games/lightsout/README.md +5 -0
A games/lightsout/elo.go +186 -0
@@ -0,0 +1,186 @@
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 := lightsOutSpecForElo(elo)27+ rng := lightsOutRandForElo(seed, elo)28+ grid, report := generateLightsOutEloGrid(spec, rng, elo)29+30+ mode := m31+ mode.Width = spec.width32+ mode.Height = spec.height33+34+ g := newFromGrid(mode.Width, mode.Height, grid)35+ return g, report, nil36+}37+38+func lightsOutSpecForElo(elo difficulty.Elo) eloSpec {39+ score := difficulty.Score01(elo)40+ size := 3 + 2*int(math.Round(score*3))41+ return eloSpec{width: size, height: size}42+}43+44+func lightsOutRandForElo(seed string, elo difficulty.Elo) *rand.Rand {45+ sum := sha256.Sum256([]byte("lightsout\x00" + seed + "\x00" + strconv.Itoa(int(elo))))46+ return rand.New(rand.NewPCG(47+ binary.LittleEndian.Uint64(sum[0:8]),48+ binary.LittleEndian.Uint64(sum[8:16]),49+ ))50+}51+52+func generateLightsOutEloGrid(spec eloSpec, rng *rand.Rand, target difficulty.Elo) ([][]bool, difficulty.Report) {53+ best := GenerateSeeded(spec.width, spec.height, rng)54+ bestReport := scoreLightsOutGrid(target, best)55+56+ for range 23 {57+ candidate := GenerateSeeded(spec.width, spec.height, rng)58+ report := scoreLightsOutGrid(target, candidate)59+ if eloDistance(report.ActualElo, target) < eloDistance(bestReport.ActualElo, target) {60+ best = candidate61+ bestReport = report62+ }63+ }64+65+ return best, bestReport66+}67+68+func scoreLightsOutGrid(target difficulty.Elo, grid [][]bool) difficulty.Report {69+ metrics := lightsOutMetrics(grid)70+ score := 0.45*normalizeLightsOut(metrics["cells"], 9, 81) +71+ 0.35*normalizeLightsOut(metrics["min_solution_moves"], 1, 41) +72+ 0.20*normalizeLightsOut(metrics["lights_on"], 1, 81)73+74+ return difficulty.Report{75+ TargetElo: target,76+ ActualElo: difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo)))),77+ Confidence: difficulty.ConfidenceHigh,78+ Metrics: metrics,79+ }80+}81+82+func lightsOutMetrics(grid [][]bool) difficulty.Metrics {83+ height := len(grid)84+ width := 085+ if height > 0 {86+ width = len(grid[0])87+ }88+ cells := width * height89+ lightsOn := 090+ for y := range grid {91+ for x := range grid[y] {92+ if grid[y][x] {93+ lightsOn++94+ }95+ }96+ }97+98+ minMoves, solved := minSolutionMoves(grid)99+ solvable := 0.0100+ if solved {101+ solvable = 1102+ }103+104+ return difficulty.Metrics{105+ "width": float64(width),106+ "height": float64(height),107+ "cells": float64(cells),108+ "lights_on": float64(lightsOn),109+ "light_density": ratio(lightsOn, cells),110+ "min_solution_moves": float64(minMoves),111+ "move_density": ratio(minMoves, cells),112+ "solvable": solvable,113+ }114+}115+116+func minSolutionMoves(grid [][]bool) (int, bool) {117+ height := len(grid)118+ if height == 0 {119+ return 0, true120+ }121+ width := len(grid[0])122+ if width == 0 {123+ return 0, true124+ }125+126+ best := width*height + 1127+ for firstRowMask := range 1 << width {128+ work := copyBoolGrid(grid)129+ moves := 0130+131+ for x := range width {132+ if firstRowMask&(1<<x) != 0 {133+ Toggle(work, x, 0)134+ moves++135+ }136+ }137+138+ for y := 1; y < height; y++ {139+ for x := range width {140+ if work[y-1][x] {141+ Toggle(work, x, y)142+ moves++143+ }144+ }145+ }146+147+ if IsSolved(work) && moves < best {148+ best = moves149+ }150+ }151+152+ if best == width*height+1 {153+ return 0, false154+ }155+ return best, true156+}157+158+func copyBoolGrid(grid [][]bool) [][]bool {159+ copied := make([][]bool, len(grid))160+ for y := range grid {161+ copied[y] = make([]bool, len(grid[y]))162+ copy(copied[y], grid[y])163+ }164+ return copied165+}166+167+func normalizeLightsOut(value, minValue, maxValue float64) float64 {168+ if maxValue <= minValue {169+ return 0170+ }171+ return math.Max(0, math.Min(1, (value-minValue)/(maxValue-minValue)))172+}173+174+func ratio(numerator, denominator int) float64 {175+ if denominator == 0 {176+ return 0177+ }178+ return float64(numerator) / float64(denominator)179+}180+181+func eloDistance(a, b difficulty.Elo) difficulty.Elo {182+ if a > b {183+ return a - b184+ }185+ return b - a186+}
A games/lightsout/elo_test.go +105 -0
@@ -0,0 +1,105 @@
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+}
M games/lightsout/gamemode.go +16 -1
M games/netwalk/README.md +5 -0
A games/netwalk/elo.go +151 -0
@@ -0,0 +1,151 @@
1+package netwalk2+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+func (m NetwalkMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {15+ if err := difficulty.ValidateElo(elo); err != nil {16+ return nil, difficulty.Report{}, err17+ }18+19+ mode := netwalkModeForElo(elo)20+ puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, netwalkEloRNG(seed, elo))21+ if err != nil {22+ return nil, difficulty.Report{}, err23+ }24+25+ gamer, err := New(mode, puzzle)26+ if err != nil {27+ return nil, difficulty.Report{}, err28+ }29+ return gamer, netwalkDifficultyReport(elo, mode, puzzle), nil30+}31+32+func netwalkModeForElo(elo difficulty.Elo) NetwalkMode {33+ score := difficulty.Score01(elo)34+ size := 5 + int(math.Round(score*4))*235+ fillRatio := 0.50 + score*0.2836+ profile := netwalkProfileForElo(score)37+38+ return NewMode(39+ "Elo "+strconv.Itoa(int(elo)),40+ "Elo-targeted Netwalk puzzle.",41+ size,42+ fillRatio,43+ profile,44+ )45+}46+47+func netwalkProfileForElo(score float64) generateProfile {48+ switch {49+ case score < 0.20:50+ return miniProfile51+ case score < 0.42:52+ return easyProfile53+ case score < 0.65:54+ return mediumProfile55+ case score < 0.86:56+ return hardProfile57+ default:58+ return expertProfile59+ }60+}61+62+func netwalkEloRNG(seed string, elo difficulty.Elo) *rand.Rand {63+ sum := sha256.Sum256([]byte("netwalk\x00" + seed + "\x00" + strconv.Itoa(int(elo))))64+ return rand.New(rand.NewPCG(65+ binary.LittleEndian.Uint64(sum[0:8]),66+ binary.LittleEndian.Uint64(sum[8:16]),67+ ))68+}69+70+func netwalkDifficultyReport(target difficulty.Elo, mode NetwalkMode, puzzle Puzzle) difficulty.Report {71+ metrics := netwalkDifficultyMetrics(mode, puzzle)72+ return difficulty.Report{73+ TargetElo: target,74+ ActualElo: netwalkActualElo(metrics),75+ Confidence: difficulty.ConfidenceMedium,76+ Metrics: metrics,77+ }78+}79+80+func netwalkDifficultyMetrics(mode NetwalkMode, puzzle Puzzle) difficulty.Metrics {81+ state := analyzePuzzle(puzzle)82+ metrics := difficulty.Metrics{83+ "size": float64(puzzle.Size),84+ "cells": float64(puzzle.Size * puzzle.Size),85+ "active_tiles": float64(state.nonEmpty),86+ "density": safeFloatRatio(state.nonEmpty, puzzle.Size*puzzle.Size),87+ "target_density": mode.FillRatio,88+ "connected_tiles": float64(state.connected),89+ "dangling_edges": float64(state.dangling),90+ "locked_tiles": float64(state.locked),91+ }92+93+ rotationOptions := 094+ for y := range puzzle.Size {95+ for x := range puzzle.Size {96+ t := puzzle.Tiles[y][x]97+ if !isActive(t) {98+ continue99+ }100+ rotationOptions += len(uniqueRotations(t.BaseMask))101+ switch degree(t.BaseMask) {102+ case 1:103+ metrics["leaf_tiles"]++104+ case 2:105+ if t.BaseMask == north|south || t.BaseMask == east|west {106+ metrics["straight_tiles"]++107+ } else {108+ metrics["elbow_tiles"]++109+ }110+ case 3:111+ metrics["tee_tiles"]++112+ case 4:113+ metrics["cross_tiles"]++114+ }115+ if t.Rotation != 0 {116+ metrics["initially_rotated_tiles"]++117+ }118+ }119+ }120+ metrics["rotation_options"] = float64(rotationOptions)121+ metrics["avg_rotation_options"] = safeFloatRatio(rotationOptions, state.nonEmpty)122+ return metrics123+}124+125+func netwalkActualElo(metrics difficulty.Metrics) difficulty.Elo {126+ score := 0.24*normalizeFloat(metrics["size"], 5, 13) +127+ 0.20*normalizeFloat(metrics["density"], 0.20, 0.80) +128+ 0.18*normalizeFloat(metrics["tee_tiles"]+metrics["cross_tiles"], 0, 18) +129+ 0.14*normalizeFloat(metrics["elbow_tiles"], 0, 32) +130+ 0.14*normalizeFloat(metrics["avg_rotation_options"], 1, 4) +131+ 0.10*normalizeFloat(metrics["initially_rotated_tiles"], 0, metrics["active_tiles"])132+133+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))134+}135+136+func normalizeFloat(value, low, high float64) float64 {137+ if high <= low || value <= low {138+ return 0139+ }140+ if value >= high {141+ return 1142+ }143+ return (value - low) / (high - low)144+}145+146+func safeFloatRatio(numerator, denominator int) float64 {147+ if denominator == 0 {148+ return 0149+ }150+ return float64(numerator) / float64(denominator)151+}
M games/netwalk/gamemode.go +16 -1
M games/netwalk/netwalk_test.go +98 -0
@@ -3,11 +3,13 @@ package netwalk
33 import (44 "math"55 "math/rand/v2"6+ "reflect"67 "strings"78 "testing"89 910 "charm.land/bubbles/v2/key"1011 tea "charm.land/bubbletea/v2"12+ "github.com/FelineStateMachine/puzzletea/difficulty"1113 "github.com/FelineStateMachine/puzzletea/game"1214 "github.com/charmbracelet/x/ansi"1315 )
@@ -98,6 +100,102 @@ func TestGenerateSeededDeterministic(t *testing.T) {
98100 }99101 }100102 103+func TestSpawnEloRejectsInvalidElo(t *testing.T) {104+ mode := NewMode("Test", "Test mode.", 7, 0.57, easyProfile)105+106+ gamer, report, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1)107+ if err == nil {108+ t.Fatal("SpawnElo returned nil error for invalid Elo")109+ }110+ if gamer != nil {111+ t.Fatalf("SpawnElo gamer = %#v, want nil", gamer)112+ }113+ if !reflect.DeepEqual(report, difficulty.Report{}) {114+ t.Fatalf("SpawnElo report = %#v, want zero report", report)115+ }116+}117+118+func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {119+ mode := NewMode("Test", "Test mode.", 7, 0.57, easyProfile)120+121+ gamerA, reportA, err := mode.SpawnElo("same-seed", 1500)122+ if err != nil {123+ t.Fatalf("first SpawnElo returned error: %v", err)124+ }125+ gamerB, reportB, err := mode.SpawnElo("same-seed", 1500)126+ if err != nil {127+ t.Fatalf("second SpawnElo returned error: %v", err)128+ }129+ if !reflect.DeepEqual(reportA, reportB) {130+ t.Fatalf("reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)131+ }132+133+ saveA, err := gamerA.GetSave()134+ if err != nil {135+ t.Fatalf("first GetSave returned error: %v", err)136+ }137+ saveB, err := gamerB.GetSave()138+ if err != nil {139+ t.Fatalf("second GetSave returned error: %v", err)140+ }141+ if string(saveA) != string(saveB) {142+ t.Fatalf("saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)143+ }144+}145+146+func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {147+ mode := NewMode("Test", "Test mode.", 7, 0.57, easyProfile)148+149+ gamer, report, err := mode.SpawnElo("report-fields", 2200)150+ if err != nil {151+ t.Fatalf("SpawnElo returned error: %v", err)152+ }153+ if gamer == nil {154+ t.Fatal("SpawnElo returned nil gamer")155+ }156+ if report.TargetElo != 2200 {157+ t.Fatalf("TargetElo = %d, want 2200", report.TargetElo)158+ }159+ if report.ActualElo < difficulty.MinElo || report.ActualElo > difficulty.SoftCapElo {160+ t.Fatalf("ActualElo = %d, want valid Elo", report.ActualElo)161+ }162+ if report.Confidence == "" {163+ t.Fatal("Confidence is empty")164+ }165+166+ required := []string{167+ "size",168+ "cells",169+ "active_tiles",170+ "density",171+ "target_density",172+ "leaf_tiles",173+ "elbow_tiles",174+ "tee_tiles",175+ "rotation_options",176+ "avg_rotation_options",177+ "initially_rotated_tiles",178+ }179+ for _, key := range required {180+ if _, ok := report.Metrics[key]; !ok {181+ t.Fatalf("metric %q missing from report %#v", key, report.Metrics)182+ }183+ }184+ if report.Metrics["active_tiles"] <= 0 {185+ t.Fatalf("active_tiles = %.2f, want > 0", report.Metrics["active_tiles"])186+ }187+ if report.Metrics["density"] <= 0 || report.Metrics["density"] > 1 {188+ t.Fatalf("density = %.2f, want 0 < density <= 1", report.Metrics["density"])189+ }190+ if report.Metrics["rotation_options"] < report.Metrics["active_tiles"] {191+ t.Fatalf(192+ "rotation_options = %.2f, want >= active_tiles %.2f",193+ report.Metrics["rotation_options"],194+ report.Metrics["active_tiles"],195+ )196+ }197+}198+101199 func TestDefaultKeyMapUsesEnterForLockAndSpaceForRotate(t *testing.T) {102200 if !key.Matches(103201 keyPress("space"),
M games/nonogram/README.md +5 -0
A games/nonogram/elo.go +162 -0
@@ -0,0 +1,162 @@
1+package nonogram2+3+import (4+ "context"5+ "crypto/sha256"6+ "encoding/binary"7+ "errors"8+ "math"9+ "math/rand/v2"10+ "strconv"11+ "time"12+13+ "github.com/FelineStateMachine/puzzletea/difficulty"14+ "github.com/FelineStateMachine/puzzletea/game"15+)16+17+var _ game.EloSpawner = NonogramMode{}18+19+func (n NonogramMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {20+ if err := difficulty.ValidateElo(elo); err != nil {21+ return nil, difficulty.Report{}, err22+ }23+24+ mode := nonogramModeForElo(elo)25+ hints := GenerateRandomTomographySeeded(mode, nonogramEloRNG(seed, elo))26+ if len(hints.rows) == 0 || len(hints.cols) == 0 {27+ return nil, difficulty.Report{}, errors.New("unable to generate Elo nonogram")28+ }29+30+ gamer, err := New(mode, hints)31+ if err != nil {32+ return nil, difficulty.Report{}, err33+ }34+ return gamer, nonogramDifficultyReport(elo, mode, hints), nil35+}36+37+func nonogramModeForElo(elo difficulty.Elo) NonogramMode {38+ score := difficulty.Score01(elo)39+40+ size := 541+ switch {42+ case score >= 0.75:43+ size = 1544+ case score >= 0.35:45+ size = 1046+ }47+ density := 0.66 - score*0.2648+49+ return NewMode(50+ "Elo "+strconv.Itoa(int(elo)),51+ "Elo-targeted Nonogram puzzle.",52+ size,53+ size,54+ density,55+ )56+}57+58+func nonogramEloRNG(seed string, elo difficulty.Elo) *rand.Rand {59+ sum := sha256.Sum256([]byte("nonogram\x00" + seed + "\x00" + strconv.Itoa(int(elo))))60+ return rand.New(rand.NewPCG(61+ binary.LittleEndian.Uint64(sum[0:8]),62+ binary.LittleEndian.Uint64(sum[8:16]),63+ ))64+}65+66+func nonogramDifficultyReport(target difficulty.Elo, mode NonogramMode, hints Hints) difficulty.Report {67+ metrics := nonogramDifficultyMetrics(mode, hints)68+ return difficulty.Report{69+ TargetElo: target,70+ ActualElo: nonogramActualElo(metrics),71+ Confidence: difficulty.ConfidenceMedium,72+ Metrics: metrics,73+ }74+}75+76+func nonogramDifficultyMetrics(mode NonogramMode, hints Hints) difficulty.Metrics {77+ cells := mode.Width * mode.Height78+ filled := 079+ rowRuns := 080+ colRuns := 081+ rowPossibilities := 082+ colPossibilities := 083+ maxLinePossibilities := 084+85+ for _, row := range hints.rows {86+ filled += hintSum(row)87+ rowRuns += nonzeroHintCount(row)88+ possibilities := len(generateLinePossibilities(mode.Width, row))89+ rowPossibilities += possibilities90+ maxLinePossibilities = max(maxLinePossibilities, possibilities)91+ }92+ for _, col := range hints.cols {93+ colRuns += nonzeroHintCount(col)94+ possibilities := len(generateLinePossibilities(mode.Height, col))95+ colPossibilities += possibilities96+ maxLinePossibilities = max(maxLinePossibilities, possibilities)97+ }98+99+ ctx, cancel := context.WithTimeout(context.Background(), time.Duration(max(mode.Width, mode.Height))*time.Second)100+ solutions := countSolutions(hints, mode.Width, mode.Height, 2, ctx)101+ cancel()102+103+ totalLines := len(hints.rows) + len(hints.cols)104+ totalPossibilities := rowPossibilities + colPossibilities105+ return difficulty.Metrics{106+ "width": float64(mode.Width),107+ "height": float64(mode.Height),108+ "cells": float64(cells),109+ "density": nonogramRatio(filled, cells),110+ "target_density": mode.Density,111+ "row_runs": float64(rowRuns),112+ "col_runs": float64(colRuns),113+ "total_runs": float64(rowRuns + colRuns),114+ "avg_runs_per_line": nonogramRatio(rowRuns+colRuns, totalLines),115+ "line_possibilities": float64(totalPossibilities),116+ "avg_line_possibilities": nonogramRatio(totalPossibilities, totalLines),117+ "max_line_possibilities": float64(maxLinePossibilities),118+ "solutions": float64(solutions),119+ }120+}121+122+func nonogramActualElo(metrics difficulty.Metrics) difficulty.Elo {123+ score := 0.34*normalizeNonogramMetric(metrics["cells"], 25, 225) +124+ 0.24*normalizeNonogramMetric(metrics["avg_line_possibilities"], 1, 120) +125+ 0.18*normalizeNonogramMetric(metrics["max_line_possibilities"], 1, 600) +126+ 0.14*(1-normalizeNonogramMetric(metrics["density"], 0.35, 0.72)) +127+ 0.10*normalizeNonogramMetric(metrics["avg_runs_per_line"], 1, 5)128+129+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))130+}131+132+func hintSum(hint []int) int {133+ total := 0134+ for _, v := range hint {135+ total += v136+ }137+ return total138+}139+140+func nonzeroHintCount(hint []int) int {141+ if len(hint) == 1 && hint[0] == 0 {142+ return 0143+ }144+ return len(hint)145+}146+147+func nonogramRatio(numerator, denominator int) float64 {148+ if denominator == 0 {149+ return 0150+ }151+ return float64(numerator) / float64(denominator)152+}153+154+func normalizeNonogramMetric(value, low, high float64) float64 {155+ if high <= low || value <= low {156+ return 0157+ }158+ if value >= high {159+ return 1160+ }161+ return (value - low) / (high - low)162+}
A games/nonogram/elo_test.go +101 -0
@@ -0,0 +1,101 @@
1+package nonogram2+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", 5, 5, 0.5)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", 5, 5, 0.5)29+ const target = difficulty.Elo(1200)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", 5, 5, 0.5)59+ const target = difficulty.Elo(1800)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+ "density": 0,83+ "target_density": 0,84+ "total_runs": 1,85+ "avg_line_possibilities": 1,86+ "max_line_possibilities": 1,87+ "solutions": 1,88+ }89+ for key, min := range requiredMetrics {90+ got, ok := report.Metrics[key]91+ if !ok {92+ t.Fatalf("Metrics[%q] missing from %#v", key, report.Metrics)93+ }94+ if got < min {95+ t.Fatalf("Metrics[%q] = %v, want >= %v", key, got, min)96+ }97+ }98+ if report.Metrics["density"] > 1 {99+ t.Fatalf("density = %v, want <= 1", report.Metrics["density"])100+ }101+}
M games/nonogram/gamemode.go +19 -1
M games/nurikabe/README.md +5 -0
A games/nurikabe/elo.go +159 -0
@@ -0,0 +1,159 @@
1+package nurikabe2+3+import (4+ "context"5+ "crypto/sha256"6+ "encoding/binary"7+ "errors"8+ "math"9+ "math/rand/v2"10+ "strconv"11+12+ "github.com/FelineStateMachine/puzzletea/difficulty"13+ "github.com/FelineStateMachine/puzzletea/game"14+)15+16+var (17+ _ game.EloSpawner = NurikabeMode{}18+ _ game.CancellableEloSpawner = NurikabeMode{}19+)20+21+func (n NurikabeMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {22+ return n.SpawnEloContext(context.Background(), seed, elo)23+}24+25+func (n NurikabeMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {26+ if err := difficulty.ValidateElo(elo); err != nil {27+ return nil, difficulty.Report{}, err28+ }29+30+ mode := nurikabeModeForElo(elo)31+ puzzle, err := GenerateSeededWithContext(ctx, mode, nurikabeEloRNG(seed, elo))32+ if err != nil {33+ return nil, difficulty.Report{}, err34+ }35+36+ gamer, err := New(mode, puzzle)37+ if err != nil {38+ return nil, difficulty.Report{}, err39+ }40+ return gamer, scoreNurikabeElo(ctx, elo, puzzle), nil41+}42+43+func nurikabeModeForElo(elo difficulty.Elo) NurikabeMode {44+ score := difficulty.Score01(elo)45+ size := 5 + int(math.Round(score*7))46+ clueDensity := 0.28 - score*0.1447+ maxIslandSize := size + int(math.Round(score*4))48+49+ return NewMode(50+ "Elo "+strconv.Itoa(int(elo)),51+ "Elo-targeted Nurikabe puzzle.",52+ size,53+ size,54+ clueDensity,55+ maxIslandSize,56+ )57+}58+59+func nurikabeEloRNG(seed string, elo difficulty.Elo) *rand.Rand {60+ sum := sha256.Sum256([]byte("nurikabe\x00" + seed + "\x00" + strconv.Itoa(int(elo))))61+ return rand.New(rand.NewPCG(62+ binary.LittleEndian.Uint64(sum[0:8]),63+ binary.LittleEndian.Uint64(sum[8:16]),64+ ))65+}66+67+func scoreNurikabeElo(ctx context.Context, target difficulty.Elo, puzzle Puzzle) difficulty.Report {68+ metrics := nurikabeDifficultyMetrics(puzzle)69+70+ solutions, stats, err := CountSolutionsContext(ctx, puzzle, 2, 200000)71+ metrics["solution_count"] = float64(solutions)72+ metrics["solver_nodes"] = float64(stats.Nodes)73+ metrics["branches"] = float64(stats.Branches)74+ metrics["max_depth"] = float64(stats.MaxDepth)75+76+ confidence := difficulty.ConfidenceHigh77+ if err != nil {78+ confidence = difficulty.ConfidenceLow79+ if errors.Is(err, errNodeLimit) ||80+ errors.Is(err, context.Canceled) ||81+ errors.Is(err, context.DeadlineExceeded) {82+ metrics["solver_limited"] = 183+ }84+ } else if solutions != 1 {85+ confidence = difficulty.ConfidenceMedium86+ }87+88+ return difficulty.Report{89+ TargetElo: target,90+ ActualElo: nurikabeActualElo(metrics),91+ Confidence: confidence,92+ Metrics: metrics,93+ }94+}95+96+func nurikabeDifficultyMetrics(puzzle Puzzle) difficulty.Metrics {97+ metrics := difficulty.Metrics{98+ "width": float64(puzzle.Width),99+ "height": float64(puzzle.Height),100+ "cells": float64(puzzle.Width * puzzle.Height),101+ "unknown_count": float64(puzzle.Width * puzzle.Height),102+ }103+104+ sizes := make([]int, 0, puzzle.Width*puzzle.Height)105+ for y := range puzzle.Height {106+ for x := range puzzle.Width {107+ clue := puzzle.Clues[y][x]108+ if clue <= 0 {109+ continue110+ }111+ metrics["clue_count"]++112+ metrics["island_cells"] += float64(clue)113+ sizes = append(sizes, clue)114+ if clue == 1 {115+ metrics["singleton_clues"]++116+ }117+ if float64(clue) > metrics["largest_island"] {118+ metrics["largest_island"] = float64(clue)119+ }120+ }121+ }122+123+ if metrics["clue_count"] > 0 {124+ metrics["average_island"] = metrics["island_cells"] / metrics["clue_count"]125+ metrics["singleton_ratio"] = metrics["singleton_clues"] / metrics["clue_count"]126+ metrics["size_entropy"] = componentSizeEntropy(sizes)127+ metrics["normalized_spread"] = normalizedSpread(sizes)128+ }129+ metrics["sea_cells"] = metrics["cells"] - metrics["island_cells"]130+ metrics["clue_density"] = metrics["clue_count"] / metrics["cells"]131+ metrics["sea_ratio"] = metrics["sea_cells"] / metrics["cells"]132+ metrics["unknown_count"] = metrics["cells"] - metrics["clue_count"]133+134+ return metrics135+}136+137+func nurikabeActualElo(metrics difficulty.Metrics) difficulty.Elo {138+ score := 0.25*normalizeNurikabeMetric(metrics["cells"], 25, 144) +139+ 0.20*normalizeNurikabeMetric(metrics["unknown_count"], 18, 130) +140+ 0.20*normalizeNurikabeMetric(math.Log10(metrics["solver_nodes"]+1), 1.2, 5.0) +141+ 0.15*normalizeNurikabeMetric(metrics["branches"], 0, 180) +142+ 0.10*normalizeNurikabeMetric(metrics["max_depth"], 0, 80) +143+ 0.10*normalizeNurikabeMetric(metrics["normalized_spread"], 0.2, 1.8)144+145+ if metrics["solution_count"] != 1 {146+ score *= 0.85147+ }148+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))149+}150+151+func normalizeNurikabeMetric(value, low, high float64) float64 {152+ if value <= low {153+ return 0154+ }155+ if value >= high {156+ return 1157+ }158+ return (value - low) / (high - low)159+}
A games/nurikabe/elo_test.go +89 -0
@@ -0,0 +1,89 @@
1+package nurikabe2+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", 5, 5, 0.28, 5)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", 5, 5, 0.28, 5)26+ const target = difficulty.Elo(600)27+28+ gameA, reportA, err := mode.SpawnElo("same-seed", target)29+ if err != nil {30+ t.Fatalf("first SpawnElo returned error: %v", err)31+ }32+ gameB, reportB, err := mode.SpawnElo("same-seed", target)33+ if err != nil {34+ t.Fatalf("second SpawnElo returned error: %v", err)35+ }36+37+ saveA, err := gameA.GetSave()38+ if err != nil {39+ t.Fatalf("first save returned error: %v", err)40+ }41+ saveB, err := gameB.GetSave()42+ if err != nil {43+ t.Fatalf("second save returned error: %v", err)44+ }45+ if string(saveA) != string(saveB) {46+ t.Fatalf("SpawnElo saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)47+ }48+ if !reflect.DeepEqual(reportA, reportB) {49+ t.Fatalf("SpawnElo reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)50+ }51+}52+53+func TestSpawnEloReportPopulated(t *testing.T) {54+ mode := NewMode("Elo", "test", 5, 5, 0.28, 5)55+ const target = difficulty.Elo(900)56+57+ gamer, report, err := mode.SpawnElo("report-seed", target)58+ if err != nil {59+ t.Fatalf("SpawnElo returned error: %v", err)60+ }61+ if gamer == nil {62+ t.Fatal("SpawnElo returned nil gamer")63+ }64+ if report.TargetElo != target {65+ t.Fatalf("TargetElo = %d, want %d", report.TargetElo, target)66+ }67+ if err := difficulty.ValidateElo(report.ActualElo); err != nil {68+ t.Fatalf("ActualElo = %d is invalid: %v", report.ActualElo, err)69+ }70+ if report.Confidence == "" {71+ t.Fatal("Confidence is empty")72+ }73+74+ for _, key := range []string{75+ "width",76+ "height",77+ "cells",78+ "clue_count",79+ "unknown_count",80+ "solution_count",81+ "solver_nodes",82+ "branches",83+ "max_depth",84+ } {85+ if _, ok := report.Metrics[key]; !ok {86+ t.Fatalf("metric %q missing from %+v", key, report.Metrics)87+ }88+ }89+}
M games/nurikabe/gamemode.go +19 -1
M games/rippleeffect/README.md +5 -0
A games/rippleeffect/elo.go +206 -0
@@ -0,0 +1,206 @@
1+package rippleeffect2+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+func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {15+ if err := difficulty.ValidateElo(elo); err != nil {16+ return nil, difficulty.Report{}, err17+ }18+19+ mode := rippleEffectModeForElo(elo)20+ puzzle, err := mode.generatePuzzleSeeded(rippleEffectEloRNG(seed, elo))21+ if err != nil {22+ return nil, difficulty.Report{}, err23+ }24+25+ gamer, err := New(mode, puzzle)26+ if err != nil {27+ return nil, difficulty.Report{}, err28+ }29+ return gamer, rippleEffectDifficultyReport(elo, mode, puzzle), nil30+}31+32+func rippleEffectModeForElo(elo difficulty.Elo) Mode {33+ score := difficulty.Score01(elo)34+ size := 5 + int(math.Round(score*4))35+ maxCage := 3 + int(math.Round(score*2))36+ givenRatio := 0.69 - score*0.2737+ profile := rippleEffectProfileForElo(score, maxCage)38+39+ return NewModeWithProfile(40+ "Elo "+strconv.Itoa(int(elo)),41+ "Elo-targeted Ripple Effect puzzle.",42+ size,43+ maxCage,44+ givenRatio,45+ profile,46+ )47+}48+49+func rippleEffectProfileForElo(score float64, maxCage int) generationProfile {50+ switch {51+ case score < 0.20:52+ return generationProfile{53+ cageWeights: []int{0, 5, 6, 3},54+ frontierSamples: 3,55+ shapeBias: shapeBiasCompact,56+ minGivensByCage: []int{0, 1, 1, 2},57+ maxSingletonCages: -1,58+ }59+ case score < 0.42:60+ return generationProfile{61+ cageWeights: []int{0, 3, 5, 4},62+ frontierSamples: 3,63+ shapeBias: shapeBiasCompact,64+ minGivensByCage: []int{0, 1, 1, 2},65+ maxSingletonCages: -1,66+ }67+ case score < 0.65:68+ return generationProfile{69+ cageWeights: growIntSlice([]int{0, 0, 3, 5, 4}, maxCage+1),70+ frontierSamples: 2,71+ shapeBias: shapeBiasNeutral,72+ minGivensByCage: make([]int, maxCage+1),73+ maxSingletonCages: 1,74+ }75+ case score < 0.86:76+ return generationProfile{77+ cageWeights: growIntSlice([]int{0, 0, 1, 4, 5}, maxCage+1),78+ frontierSamples: 3,79+ shapeBias: shapeBiasWinding,80+ minGivensByCage: make([]int, maxCage+1),81+ maxSingletonCages: 1,82+ }83+ default:84+ return generationProfile{85+ cageWeights: growIntSlice([]int{0, 0, 1, 2, 4, 5}, maxCage+1),86+ frontierSamples: 4,87+ shapeBias: shapeBiasWinding,88+ minGivensByCage: make([]int, maxCage+1),89+ maxSingletonCages: 1,90+ }91+ }92+}93+94+func rippleEffectEloRNG(seed string, elo difficulty.Elo) *rand.Rand {95+ sum := sha256.Sum256([]byte("rippleeffect\x00" + seed + "\x00" + strconv.Itoa(int(elo))))96+ return rand.New(rand.NewPCG(97+ binary.LittleEndian.Uint64(sum[0:8]),98+ binary.LittleEndian.Uint64(sum[8:16]),99+ ))100+}101+102+func rippleEffectDifficultyReport(target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {103+ geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)104+ if err != nil {105+ return difficulty.Report{106+ TargetElo: target,107+ ActualElo: target,108+ Confidence: difficulty.ConfidenceLow,109+ Metrics: difficulty.Metrics{110+ "width": float64(puzzle.Width),111+ "height": float64(puzzle.Height),112+ },113+ }114+ }115+116+ solutions := countSolutions(geo, puzzle.Givens, 2)117+ metrics := rippleEffectDifficultyMetrics(mode, puzzle, geo, solutions)118+ confidence := difficulty.ConfidenceHigh119+ if solutions != 1 {120+ confidence = difficulty.ConfidenceLow121+ }122+123+ return difficulty.Report{124+ TargetElo: target,125+ ActualElo: rippleEffectActualElo(metrics),126+ Confidence: confidence,127+ Metrics: metrics,128+ }129+}130+131+func rippleEffectDifficultyMetrics(mode Mode, puzzle Puzzle, geo *geometry, solutions int) difficulty.Metrics {132+ cells := puzzle.Width * puzzle.Height133+ givens := countFilledCells(puzzle.Givens)134+ empty := cells - givens135+ cageCount := len(puzzle.Cages)136+ singletons := 0137+ maxCageSize := 0138+ totalCageSize := 0139+ for _, cage := range puzzle.Cages {140+ size := len(cage.Cells)141+ if size == 1 {142+ singletons++143+ }144+ if size > maxCageSize {145+ maxCageSize = size146+ }147+ totalCageSize += size148+ }149+150+ return difficulty.Metrics{151+ "width": float64(puzzle.Width),152+ "height": float64(puzzle.Height),153+ "cells": float64(cells),154+ "max_cage": float64(mode.MaxCage),155+ "actual_max_cage": float64(maxCageSize),156+ "cage_count": float64(cageCount),157+ "avg_cage_size": rippleEffectRatio(totalCageSize, cageCount),158+ "singleton_cages": float64(singletons),159+ "given_count": float64(givens),160+ "empty_count": float64(empty),161+ "given_density": rippleEffectRatio(givens, cells),162+ "target_givens": mode.GivenRatio,163+ "solution_count": float64(solutions),164+ "geometry_cages": float64(len(geo.cages)),165+ }166+}167+168+func countFilledCells(g grid) int {169+ count := 0170+ for y := range g {171+ for x := range g[y] {172+ if g[y][x] != 0 {173+ count++174+ }175+ }176+ }177+ return count178+}179+180+func rippleEffectActualElo(metrics difficulty.Metrics) difficulty.Elo {181+ score := 0.26*rippleEffectNormalize(metrics["cells"], 25, 81) +182+ 0.22*rippleEffectNormalize(1-metrics["given_density"], 0.20, 0.65) +183+ 0.18*rippleEffectNormalize(metrics["actual_max_cage"], 3, 5) +184+ 0.14*rippleEffectNormalize(metrics["avg_cage_size"], 1.5, 4.2) +185+ 0.12*rippleEffectNormalize(metrics["empty_count"], 8, 52) +186+ 0.08*(1-rippleEffectNormalize(metrics["singleton_cages"], 0, 6))187+188+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))189+}190+191+func rippleEffectRatio(numerator, denominator int) float64 {192+ if denominator == 0 {193+ return 0194+ }195+ return float64(numerator) / float64(denominator)196+}197+198+func rippleEffectNormalize(value, low, high float64) float64 {199+ if high <= low || value <= low {200+ return 0201+ }202+ if value >= high {203+ return 1204+ }205+ return (value - low) / (high - low)206+}
M games/rippleeffect/gamemode.go +19 -1
M games/rippleeffect/rippleeffect_test.go +97 -0
@@ -5,10 +5,12 @@ import (
55 "image"66 "image/color"77 "math/rand/v2"8+ "reflect"89 "strings"910 "testing"1011 1112 "charm.land/lipgloss/v2"13+ "github.com/FelineStateMachine/puzzletea/difficulty"1214 "github.com/FelineStateMachine/puzzletea/game"1315 "github.com/FelineStateMachine/puzzletea/theme"1416 "github.com/charmbracelet/x/ansi"
@@ -187,6 +189,101 @@ func TestGeneratePuzzleSeeded(t *testing.T) {
187189 }188190 }189191 192+func TestSpawnEloRejectsInvalidElo(t *testing.T) {193+ mode := Modes[1].(Mode)194+195+ gamer, report, err := mode.SpawnElo("seed", difficulty.SoftCapElo+1)196+ if err == nil {197+ t.Fatal("SpawnElo returned nil error for invalid Elo")198+ }199+ if gamer != nil {200+ t.Fatalf("SpawnElo gamer = %#v, want nil", gamer)201+ }202+ if !reflect.DeepEqual(report, difficulty.Report{}) {203+ t.Fatalf("SpawnElo report = %#v, want zero report", report)204+ }205+}206+207+func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {208+ mode := Modes[1].(Mode)209+210+ gamerA, reportA, err := mode.SpawnElo("same-seed", 1500)211+ if err != nil {212+ t.Fatalf("first SpawnElo returned error: %v", err)213+ }214+ gamerB, reportB, err := mode.SpawnElo("same-seed", 1500)215+ if err != nil {216+ t.Fatalf("second SpawnElo returned error: %v", err)217+ }218+ if !reflect.DeepEqual(reportA, reportB) {219+ t.Fatalf("reports differ for same seed and Elo:\n%#v\n%#v", reportA, reportB)220+ }221+222+ saveA, err := gamerA.GetSave()223+ if err != nil {224+ t.Fatalf("first GetSave returned error: %v", err)225+ }226+ saveB, err := gamerB.GetSave()227+ if err != nil {228+ t.Fatalf("second GetSave returned error: %v", err)229+ }230+ if string(saveA) != string(saveB) {231+ t.Fatalf("saves differ for same seed and Elo:\n%s\n%s", saveA, saveB)232+ }233+}234+235+func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {236+ mode := Modes[1].(Mode)237+238+ gamer, report, err := mode.SpawnElo("report-fields", 1500)239+ if err != nil {240+ t.Fatalf("SpawnElo returned error: %v", err)241+ }242+ if gamer == nil {243+ t.Fatal("SpawnElo returned nil gamer")244+ }245+ if report.TargetElo != 1500 {246+ t.Fatalf("TargetElo = %d, want 1500", report.TargetElo)247+ }248+ if report.ActualElo < difficulty.MinElo || report.ActualElo > difficulty.SoftCapElo {249+ t.Fatalf("ActualElo = %d, want valid Elo", report.ActualElo)250+ }251+ if report.Confidence == "" {252+ t.Fatal("Confidence is empty")253+ }254+255+ required := []string{256+ "width",257+ "height",258+ "cells",259+ "max_cage",260+ "actual_max_cage",261+ "cage_count",262+ "avg_cage_size",263+ "singleton_cages",264+ "given_count",265+ "empty_count",266+ "given_density",267+ "target_givens",268+ "solution_count",269+ "geometry_cages",270+ }271+ for _, key := range required {272+ if _, ok := report.Metrics[key]; !ok {273+ t.Fatalf("metric %q missing from report %#v", key, report.Metrics)274+ }275+ }276+ if report.Metrics["cells"] <= 0 {277+ t.Fatalf("cells = %.2f, want > 0", report.Metrics["cells"])278+ }279+ if report.Metrics["given_density"] <= 0 || report.Metrics["given_density"] > 1 {280+ t.Fatalf("given_density = %.2f, want 0 < density <= 1", report.Metrics["given_density"])281+ }282+ if report.Metrics["solution_count"] != 1 {283+ t.Fatalf("solution_count = %.2f, want 1", report.Metrics["solution_count"])284+ }285+}286+190287 func TestMinGivensForCageAllowsZero(t *testing.T) {191288 profile := generationProfile{minGivensByCage: []int{0, 0, 0, 0}}192289
M games/shikaku/README.md +5 -0
A games/shikaku/elo.go +255 -0
@@ -0,0 +1,255 @@
1+package shikaku2+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 = ShikakuMode{}15+16+type shikakuEloSolveStats struct {17+ Nodes int18+ Branches int19+ MaxDepth int20+}21+22+func (s ShikakuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {23+ if err := difficulty.ValidateElo(elo); err != nil {24+ return nil, difficulty.Report{}, err25+ }26+27+ mode := shikakuModeForElo(elo)28+ puzzle, err := GeneratePuzzleSeeded(mode.Width, mode.Height, mode.MaxRectSize, shikakuEloRNG(seed, elo))29+ if err != nil {30+ return nil, difficulty.Report{}, err31+ }32+ return New(mode, puzzle), scoreShikakuElo(elo, mode, puzzle), nil33+}34+35+func shikakuModeForElo(elo difficulty.Elo) ShikakuMode {36+ score := difficulty.Score01(elo)37+ size := 5 + int(math.Round(score*7))38+ maxRectSize := 5 + int(math.Round(score*15))39+ return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted Shikaku puzzle.", size, size, maxRectSize)40+}41+42+func shikakuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {43+ sum := sha256.Sum256([]byte("shikaku\x00" + seed + "\x00" + strconv.Itoa(int(elo))))44+ return rand.New(rand.NewPCG(45+ binary.LittleEndian.Uint64(sum[0:8]),46+ binary.LittleEndian.Uint64(sum[8:16]),47+ ))48+}49+50+func scoreShikakuElo(target difficulty.Elo, mode ShikakuMode, puzzle Puzzle) difficulty.Report {51+ solutions, stats := countShikakuSolutionsWithEloStats(puzzle, 2)52+ metrics := shikakuDifficultyMetrics(mode, puzzle, solutions, stats)53+ confidence := difficulty.ConfidenceHigh54+ if solutions != 1 {55+ confidence = difficulty.ConfidenceMedium56+ }57+ return difficulty.Report{58+ TargetElo: target,59+ ActualElo: shikakuActualElo(metrics),60+ Confidence: confidence,61+ Metrics: metrics,62+ }63+}64+65+func shikakuDifficultyMetrics(66+ mode ShikakuMode,67+ puzzle Puzzle,68+ solutions int,69+ stats shikakuEloSolveStats,70+) difficulty.Metrics {71+ cells := puzzle.Width * puzzle.Height72+ clueCount := len(puzzle.Clues)73+ metrics := difficulty.Metrics{74+ "width": float64(puzzle.Width),75+ "height": float64(puzzle.Height),76+ "cells": float64(cells),77+ "clue_count": float64(clueCount),78+ "max_rect_size": float64(mode.MaxRectSize),79+ "solution_count": float64(solutions),80+ "solver_nodes": float64(stats.Nodes),81+ "branches": float64(stats.Branches),82+ "max_depth": float64(stats.MaxDepth),83+ }84+ if cells == 0 || clueCount == 0 {85+ return metrics86+ }87+88+ totalCandidates := 089+ maxCandidates := 090+ for _, clue := range puzzle.Clues {91+ if clue.Value == 1 {92+ metrics["singleton_clues"]++93+ }94+ if float64(clue.Value) > metrics["largest_clue"] {95+ metrics["largest_clue"] = float64(clue.Value)96+ }97+ candidateCount := len((&puzzle).CandidateRectanglesForClue(clue.ID))98+ totalCandidates += candidateCount99+ if candidateCount > maxCandidates {100+ maxCandidates = candidateCount101+ }102+ }103+ metrics["average_rect_area"] = float64(cells) / float64(clueCount)104+ metrics["clue_density"] = float64(clueCount) / float64(cells)105+ metrics["singleton_ratio"] = metrics["singleton_clues"] / float64(clueCount)106+ metrics["candidate_count"] = float64(totalCandidates)107+ metrics["average_candidates"] = float64(totalCandidates) / float64(clueCount)108+ metrics["max_candidates"] = float64(maxCandidates)109+110+ return metrics111+}112+113+func countShikakuSolutionsWithEloStats(puzzle Puzzle, limit int) (int, shikakuEloSolveStats) {114+ if limit <= 0 || puzzle.Width <= 0 || puzzle.Height <= 0 {115+ return 0, shikakuEloSolveStats{}116+ }117+118+ candidates := make(map[int][]Rectangle, len(puzzle.Clues))119+ for _, clue := range puzzle.Clues {120+ candidates[clue.ID] = (&puzzle).CandidateRectanglesForClue(clue.ID)121+ if len(candidates[clue.ID]) == 0 {122+ return 0, shikakuEloSolveStats{}123+ }124+ }125+126+ covered := make([][]bool, puzzle.Height)127+ for y := range puzzle.Height {128+ covered[y] = make([]bool, puzzle.Width)129+ }130+ placed := make(map[int]bool, len(puzzle.Clues))131+ stats := shikakuEloSolveStats{}132+ count := countShikakuSolutionsRec(puzzle, candidates, covered, placed, 0, limit, 0, &stats)133+ return count, stats134+}135+136+func countShikakuSolutionsRec(137+ puzzle Puzzle,138+ candidates map[int][]Rectangle,139+ covered [][]bool,140+ placed map[int]bool,141+ coveredCells int,142+ limit int,143+ depth int,144+ stats *shikakuEloSolveStats,145+) int {146+ stats.Nodes++147+ if depth > stats.MaxDepth {148+ stats.MaxDepth = depth149+ }150+ if len(placed) == len(puzzle.Clues) {151+ if coveredCells == puzzle.Width*puzzle.Height {152+ return 1153+ }154+ return 0155+ }156+157+ clueID, choices := shikakuNextEloChoices(puzzle, candidates, covered, placed)158+ if len(choices) == 0 {159+ return 0160+ }161+162+ total := 0163+ for _, rect := range choices {164+ stats.Branches++165+ placed[clueID] = true166+ markShikakuCovered(covered, rect, true)167+ total += countShikakuSolutionsRec(168+ puzzle,169+ candidates,170+ covered,171+ placed,172+ coveredCells+rect.Area(),173+ limit-total,174+ depth+1,175+ stats,176+ )177+ markShikakuCovered(covered, rect, false)178+ delete(placed, clueID)179+ if total >= limit {180+ return total181+ }182+ }183+ return total184+}185+186+func shikakuNextEloChoices(187+ puzzle Puzzle,188+ candidates map[int][]Rectangle,189+ covered [][]bool,190+ placed map[int]bool,191+) (int, []Rectangle) {192+ bestID := -1193+ var best []Rectangle194+ for _, clue := range puzzle.Clues {195+ if placed[clue.ID] {196+ continue197+ }198+ choices := make([]Rectangle, 0, len(candidates[clue.ID]))199+ for _, rect := range candidates[clue.ID] {200+ if shikakuRectAvailable(covered, rect) {201+ choices = append(choices, rect)202+ }203+ }204+ if bestID == -1 || len(choices) < len(best) {205+ bestID = clue.ID206+ best = choices207+ if len(best) == 0 {208+ break209+ }210+ }211+ }212+ return bestID, best213+}214+215+func shikakuRectAvailable(covered [][]bool, rect Rectangle) bool {216+ for y := rect.Y; y < rect.Y+rect.H; y++ {217+ for x := rect.X; x < rect.X+rect.W; x++ {218+ if covered[y][x] {219+ return false220+ }221+ }222+ }223+ return true224+}225+226+func markShikakuCovered(covered [][]bool, rect Rectangle, value bool) {227+ for y := rect.Y; y < rect.Y+rect.H; y++ {228+ for x := rect.X; x < rect.X+rect.W; x++ {229+ covered[y][x] = value230+ }231+ }232+}233+234+func shikakuActualElo(metrics difficulty.Metrics) difficulty.Elo {235+ score := 0.30*normalizeShikakuMetric(metrics["cells"], 25, 144) +236+ 0.20*normalizeShikakuMetric(metrics["average_candidates"], 1.5, 8.0) +237+ 0.20*normalizeShikakuMetric(math.Log10(metrics["solver_nodes"]+1), 1.0, 5.0) +238+ 0.15*normalizeShikakuMetric(metrics["branches"], 8, 250) +239+ 0.10*normalizeShikakuMetric(metrics["max_rect_size"], 5, 20) +240+ 0.05*(1-normalizeShikakuMetric(metrics["clue_density"], 0.18, 0.55))241+ if metrics["solution_count"] != 1 {242+ score *= 0.85243+ }244+ return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))245+}246+247+func normalizeShikakuMetric(value, low, high float64) float64 {248+ if high <= low || value <= low {249+ return 0250+ }251+ if value >= high {252+ return 1253+ }254+ return (value - low) / (high - low)255+}
A games/shikaku/elo_test.go +111 -0
@@ -0,0 +1,111 @@
1+package shikaku2+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, 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, 5)36+ const seed = "shikaku-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.GetSave()49+ if err != nil {50+ t.Fatalf("first GetSave returned error: %v", err)51+ }52+ saveB, err := gamerB.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, 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+ "clue_count": 1,91+ "max_rect_size": 1,92+ "average_rect_area": 1,93+ "clue_density": 0,94+ "candidate_count": 1,95+ "average_candidates": 1,96+ "max_candidates": 1,97+ "solution_count": 1,98+ "solver_nodes": 1,99+ "branches": 1,100+ "max_depth": 1,101+ }102+ for key, min := range requiredMetrics {103+ got, ok := report.Metrics[key]104+ if !ok {105+ t.Fatalf("Metrics[%q] missing from %#v", key, report.Metrics)106+ }107+ if got < min {108+ t.Fatalf("Metrics[%q] = %f, want >= %f", key, got, min)109+ }110+ }111+}
M games/shikaku/gamemode.go +19 -1
M games/spellpuzzle/README.md +5 -0
A games/spellpuzzle/elo.go +157 -0
@@ -0,0 +1,157 @@
1+package spellpuzzle2+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+func (SpellPuzzleMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {15+ if err := difficulty.ValidateElo(elo); err != nil {16+ return nil, difficulty.Report{}, err17+ }18+19+ mode := spellPuzzleModeForElo(elo)20+ puzzle, err := GeneratePuzzleSeeded(mode, randForElo(seed, elo))21+ if err != nil {22+ return nil, difficulty.Report{}, err23+ }24+25+ report := scoreEloPuzzle(elo, puzzle)26+ g, err := New(mode, puzzle)27+ if err != nil {28+ return nil, difficulty.Report{}, err29+ }30+ return g, report, nil31+}32+33+func spellPuzzleModeForElo(elo difficulty.Elo) SpellPuzzleMode {34+ score := difficulty.Score01(elo)35+ bankSize := 6 + int(math.Round(score*3))36+ boardWords := 4 + int(math.Round(score*5))37+ minBonusWords := 3 + int(math.Round(score*5))38+39+ return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted spell puzzle.", bankSize, boardWords, minBonusWords)40+}41+42+func randForElo(seed string, elo difficulty.Elo) *rand.Rand {43+ sum := sha256.Sum256([]byte("spellpuzzle\x00" + seed + "\x00" + strconv.Itoa(int(elo))))44+ return rand.New(rand.NewPCG(45+ binary.LittleEndian.Uint64(sum[0:8]),46+ binary.LittleEndian.Uint64(sum[8:16]),47+ ))48+}49+50+func scoreEloPuzzle(target difficulty.Elo, puzzle GeneratedPuzzle) difficulty.Report {51+ metrics := spellPuzzleMetrics(puzzle)52+ score := normalizedMetric(metrics["bank_size"], 6, 9)*0.20 +53+ normalizedMetric(metrics["board_word_count"], 4, 9)*0.20 +54+ normalizedMetric(metrics["bonus_word_count"], 3, 16)*0.15 +55+ normalizedMetric(metrics["average_word_length"], 3, 9)*0.15 +56+ normalizedMetric(metrics["crossing_count"], 1, 8)*0.15 +57+ normalizedMetric(metrics["allowed_word_count"], 10, 80)*0.1558+59+ return difficulty.Report{60+ TargetElo: target,61+ ActualElo: difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))),62+ Confidence: difficulty.ConfidenceMedium,63+ Metrics: metrics,64+ }65+}66+67+func spellPuzzleMetrics(puzzle GeneratedPuzzle) difficulty.Metrics {68+ minLength, maxLength, averageLength := wordLengthStats(puzzle.Placements)69+ repeatedLetters, entropy := bankLetterStats(puzzle.Bank)70+ board := buildBoard(puzzle.Placements)71+72+ return difficulty.Metrics{73+ "bank_size": float64(len([]rune(puzzle.Bank))),74+ "board_word_count": float64(len(puzzle.Placements)),75+ "bonus_word_count": float64(len(puzzle.BonusWords)),76+ "allowed_word_count": float64(len(puzzle.AllowedWord)),77+ "min_word_length": float64(minLength),78+ "max_word_length": float64(maxLength),79+ "average_word_length": averageLength,80+ "crossing_count": float64(crossingCount(puzzle.Placements)),81+ "repeated_letters": float64(repeatedLetters),82+ "letter_entropy": entropy,83+ "board_width": float64(board.Width),84+ "board_height": float64(board.Height),85+ "occupied_cell_count": float64(occupiedCellCount(board)),86+ }87+}88+89+func wordLengthStats(placements []WordPlacement) (minLength, maxLength int, average float64) {90+ if len(placements) == 0 {91+ return 0, 0, 092+ }93+94+ minLength = len(placements[0].Text)95+ total := 096+ for _, placement := range placements {97+ length := len(placement.Text)98+ minLength = min(minLength, length)99+ maxLength = max(maxLength, length)100+ total += length101+ }102+ return minLength, maxLength, float64(total) / float64(len(placements))103+}104+105+func bankLetterStats(bank string) (repeatedLetters int, entropy float64) {106+ counts := make(map[rune]int)107+ for _, letter := range bank {108+ counts[letter]++109+ }110+ for _, count := range counts {111+ if count > 1 {112+ repeatedLetters += count - 1113+ }114+ probability := float64(count) / float64(len([]rune(bank)))115+ entropy -= probability * math.Log2(probability)116+ }117+ return repeatedLetters, entropy118+}119+120+func crossingCount(placements []WordPlacement) int {121+ counts := make(map[Position]int)122+ for _, placement := range placements {123+ for _, pos := range placement.Positions() {124+ counts[pos]++125+ }126+ }127+128+ crossings := 0129+ for _, count := range counts {130+ if count > 1 {131+ crossings++132+ }133+ }134+ return crossings135+}136+137+func occupiedCellCount(board board) int {138+ count := 0139+ for y := range board.Cells {140+ for x := range board.Cells[y] {141+ if board.Cells[y][x].Occupied {142+ count++143+ }144+ }145+ }146+ return count147+}148+149+func normalizedMetric(value, low, high float64) float64 {150+ if value <= low {151+ return 0152+ }153+ if value >= high {154+ return 1155+ }156+ return (value - low) / (high - low)157+}
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 +149 -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 +147 -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 +186 -0

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

A games/takuzu/elo_test.go +107 -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 +225 -0

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

A games/takuzuplus/elo_test.go +100 -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 +206 -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 +16 -1

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

M resolve/resolve.go +12 -4

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

M session/session.go +49 -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 +14 -4

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

M stats/stats_test.go +6 -0

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

M store/game_record.go +21 -18

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

M store/migrate.go +47 -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 +27 -3

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

M store/store_test.go +14 -0

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