clone

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

Fix create pool selection and Elo candidate handling

Dami committed 8f1fb66
commit
8f1fb6606286a192e6927252b2739a71e88a1b8d
parent
ac55f10ff9a85fe0ba2e1d27a45cbc14bcc126ce
author
Dami <dami.etoile@gmail.com>
26 files changed, +827 -182
Mapp/create_state.go47++++++++++++++-----
Mapp/create_state_test.go62++++++++++++++++++------
Mapp/screen_create_test.go59++++++++++++++++++------
Adifficulty/candidates.go43+++++++++++++++++
Mgames/fillomino/elo.go52++++++++++++++++++---
Mgames/hashiwokakero/elo.go37+++++++++++++--
Mgames/hitori/elo.go75++++++++++++++++++++++++++----
Mgames/hitori/elo_test.go18+++++++
Mgames/hitori/generator.go54++++++++++++++++------
Mgames/netwalk/elo.go43++++++++++++++---
Mgames/nonogram/elo.go55+++++++++++++++++-----
Mgames/nonogram/elo_test.go20++++++++-
Mgames/nonogram/generator.go27++++++++++-
Mgames/nurikabe/elo.go27+++++++++--
Mgames/rippleeffect/elo.go30+++++++++---
Mgames/rippleeffect/rippleeffect_test.go18+++++++
Mgames/rippleeffect/solver.go23++++++++--
Mgames/shikaku/elo.go43++++++++++++++---
Mgames/spellpuzzle/elo.go50+++++++++++++++-----
Mgames/sudoku/elo.go24++++++++-
Mgames/sudokurgb/elo.go32++++++++++---
Mgames/takuzu/elo.go39++++++++++++---
Mgames/takuzu/elo_test.go18+++++++
Mgames/takuzuplus/elo.go38++++++++++++---
Mgames/takuzuplus/elo_test.go18+++++++
Mgames/wordsearch/elo.go57+++++++++++++++--------
M app/create_state.go +34 -13
@@ -73,8 +73,10 @@ func newCreateState(cfg config.CreateConfig, width int) createState {
7373 seedInput.CharLimit = 647474 seedInput.SetWidth(min(width, 48))7575 76+ tree := buildCreateTree(registry.Entries())77+ validLeafIDs := createLeafIDSet(tree)7678 state := createState{77- tree: buildCreateTree(registry.Entries()),79+ tree: tree,7880 checked: make(map[string]bool),7981 expanded: make(map[string]bool),8082 eloInput: eloInput,
@@ -82,6 +84,9 @@ func newCreateState(cfg config.CreateConfig, width int) createState {
8284 focus: createFocusTree,8385 }8486 for _, id := range cfg.SelectedLeafIDs {87+ if !validLeafIDs[id] {88+ continue89+ }8590 state.checked[id] = true8691 state.expandAncestors(id)8792 }
@@ -127,6 +132,10 @@ func (b *createNodeBuilder) addBranch(entry registry.Entry, branchTitle string)
127132 }128133 129134 branchID := b.id + "/branch:" + puzzle.NormalizeName(branchTitle)135+ if len(leaves) == 1 {136+ b.branches = append(b.branches, leaves[0])137+ return138+ }130139 b.branches = append(b.branches, createTreeNode{131140 id: branchID,132141 title: branchTitle,
@@ -135,6 +144,9 @@ func (b *createNodeBuilder) addBranch(entry registry.Entry, branchTitle string)
135144 }136145 137146 func (b *createNodeBuilder) node() createTreeNode {147+ if len(b.branches) == 1 && b.branches[0].leaf != nil {148+ return b.branches[0]149+ }138150 return createTreeNode{id: b.id, title: b.title, children: b.branches}139151 }140152
@@ -196,24 +208,17 @@ func (s createState) selectedLeaves() []createLeaf {
196208 }197209 198210 func (s createState) selectedLeafIDs() []string {199- ids := make([]string, 0, len(s.checked))200- for id, checked := range s.checked {201- if checked {202- ids = append(ids, id)203- }211+ leaves := s.selectedLeaves()212+ ids := make([]string, 0, len(leaves))213+ for _, leaf := range leaves {214+ ids = append(ids, leaf.id)204215 }205216 sort.Strings(ids)206217 return ids207218 }208219 209220 func (s createState) selectedCount() int {210- count := 0211- for _, checked := range s.checked {212- if checked {213- count++214- }215- }216- return count221+ return len(s.selectedLeaves())217222 }218223 219224 func (s createState) descendantLeafCounts(node createTreeNode) (selected, total int) {
@@ -315,3 +320,19 @@ func (s createState) resolveLeafVariant(leaf createLeaf, elo difficulty.Elo) (ga
315320 }316321 return leaf.variant.Elo, leaf.variant.Definition.Title, nil317322 }323+324+func createLeafIDSet(nodes []createTreeNode) map[string]bool {325+ ids := make(map[string]bool)326+ var walk func([]createTreeNode)327+ walk = func(nodes []createTreeNode) {328+ for _, node := range nodes {329+ if node.leaf != nil {330+ ids[node.leaf.id] = true331+ continue332+ }333+ walk(node.children)334+ }335+ }336+ walk(nodes)337+ return ids338+}
M app/create_state_test.go +46 -16
@@ -20,8 +20,32 @@ func TestCreateStateStartsCollapsedAndRestoresCheckedLeaves(t *testing.T) {
2020 if got := state.eloInput.Value(); got != "1500" {2121 t.Fatalf("elo input = %q, want 1500", got)2222 }23- if len(state.visibleNodes()) <= len(state.tree) {24- t.Fatalf("expected restored leaf ancestors to be expanded")23+ for _, item := range state.visibleNodes() {24+ if item.node.leaf != nil && item.node.leaf.id == leafID {25+ return26+ }27+ }28+ t.Fatalf("visible nodes = %#v, want restored top-level leaf %q", state.visibleNodes(), leafID)29+}30+31+func TestCreateStateIgnoresUnknownRestoredLeafIDs(t *testing.T) {32+ const validLeafID = "nonogram/nonogram"33+ state := newCreateState(config.CreateConfig{34+ SelectedLeafIDs: []string{35+ "nonogram/5x5",36+ validLeafID,37+ "stale/game/mode",38+ },39+ }, 80)40+41+ if got := state.selectedCount(); got != 1 {42+ t.Fatalf("selected count = %d, want 1", got)43+ }44+ if got := state.selectedLeafIDs(); len(got) != 1 || got[0] != validLeafID {45+ t.Fatalf("selected leaf IDs = %#v, want [%q]", got, validLeafID)46+ }47+ if len(state.checked) != 1 || !state.checked[validLeafID] {48+ t.Fatalf("checked map = %#v, want only %q", state.checked, validLeafID)2549 }2650 }2751
@@ -40,33 +64,39 @@ func TestCreateTreeGroupsTakuzuAndSudokuVariants(t *testing.T) {
4064 if !hasCreateChild(takuzu, "Takuzu") || !hasCreateChild(takuzu, "Takuzu+") {4165 t.Fatalf("Takuzu group children = %#v, want Takuzu and Takuzu+", childTitles(takuzu))4266 }67+ for _, child := range takuzu.children {68+ if child.leaf == nil {69+ t.Fatalf("Takuzu child %q = %#v, want leaf", child.title, child)70+ }71+ }4372 4473 sudoku := requireCreateNode(t, state.tree, "Sudoku")4574 if !hasCreateChild(sudoku, "Sudoku") || !hasCreateChild(sudoku, "Sudoku RGB") {4675 t.Fatalf("Sudoku group children = %#v, want Sudoku and Sudoku RGB", childTitles(sudoku))4776 }77+ for _, child := range sudoku.children {78+ if child.leaf == nil {79+ t.Fatalf("Sudoku child %q = %#v, want leaf", child.title, child)80+ }81+ }4882 }4983 50-func TestCreateTreeListsNonogramRuleVariant(t *testing.T) {84+func TestCreateTreePromotesSingleVariantGameToTopLevelLeaf(t *testing.T) {5185 state := newCreateState(config.CreateConfig{}, 80)5286 nonogram := requireCreateNode(t, state.tree, "Nonogram")5387 54- want := []string{"Nonogram"}55- got := childTitles(nonogram)56- if len(got) != len(want) {57- t.Fatalf("Nonogram leaves = %#v, want %#v", got, want)88+ if nonogram.leaf == nil {89+ t.Fatalf("Nonogram node = %#v, want top-level leaf", nonogram)5890 }59- for i := range want {60- if got[i] != want[i] {61- t.Fatalf("Nonogram leaves = %#v, want %#v", got, want)62- }91+ if len(nonogram.children) != 0 {92+ t.Fatalf("Nonogram children = %#v, want none", childTitles(nonogram))6393 }6494 }6595 6696 func TestCreateResolveLeafVariantUsesSelectedVariant(t *testing.T) {6797 state := newCreateState(config.CreateConfig{}, 80)6898 nonogram := requireCreateNode(t, state.tree, "Nonogram")69- leaf := requireCreateNode(t, nonogram.children, "Nonogram").leaf99+ leaf := nonogram.leaf70100 if leaf == nil {71101 t.Fatal("Nonogram node is not a leaf")72102 }
@@ -83,11 +113,11 @@ func TestCreateResolveLeafVariantUsesSelectedVariant(t *testing.T) {
83113 func TestCreateStateDescendantLeafCountsIncludeCollapsedDescendants(t *testing.T) {84114 state := newCreateState(config.CreateConfig{}, 80)85115 nonogram := requireCreateNode(t, state.tree, "Nonogram")86- state.checked[nonogram.children[0].leaf.id] = true116+ state.checked[nonogram.leaf.id] = true87117 88118 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))119+ if selected != 1 || total != 1 {120+ t.Fatalf("descendant counts = %d/%d, want 1/1", selected, total)91121 }92122 }93123
@@ -117,7 +147,7 @@ func TestCreateStateToggleDescendantLeavesClearsFullySelectedParent(t *testing.T
117147 }118148 }119149 120-func TestCreateStateToggleDescendantLeavesWorksForNestedVariantBranch(t *testing.T) {150+func TestCreateStateToggleDescendantLeavesWorksForGroupedLeaf(t *testing.T) {121151 state := newCreateState(config.CreateConfig{}, 80)122152 takuzu := requireCreateNode(t, state.tree, "Takuzu")123153 takuzuPlus := requireCreateNode(t, takuzu.children, "Takuzu+")
M app/screen_create_test.go +44 -15
@@ -12,8 +12,10 @@ import (
1212 1313 func TestCreateScreenSpaceTogglesParentDescendantsAndLeaves(t *testing.T) {1414 state := newCreateState(config.CreateConfig{}, 80)15+ parentIndex := visibleNodeIndexByTitle(t, state, "Sudoku")16+ state.cursor = parentIndex1517 screen := createScreen{create: state}16- parent := state.visibleNodes()[0].node18+ parent := state.visibleNodes()[parentIndex].node1719 _, parentTotal := state.descendantLeafCounts(parent)1820 1921 next, _, _ := screen.Update(tea.KeyPressMsg{Code: tea.KeySpace})
@@ -22,9 +24,8 @@ func TestCreateScreenSpaceTogglesParentDescendantsAndLeaves(t *testing.T) {
2224 t.Fatalf("selected count after parent space = %d, want %d", got.create.selectedCount(), parentTotal)2325 }2426 25- next, _, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyEnter})26- got = next.(createScreen)27- got.create.cursor = 127+ got.create.expanded[parent.id] = true28+ got.create.cursor = visibleLeafIndexByID(t, got.create, firstDescendantLeafID(t, parent.children[0]))2829 next, _, _ = got.Update(tea.KeyPressMsg{Code: tea.KeySpace})2930 got = next.(createScreen)3031 if got.create.selectedCount() != parentTotal-1 {
@@ -33,8 +34,10 @@ func TestCreateScreenSpaceTogglesParentDescendantsAndLeaves(t *testing.T) {
3334 }3435 3536 func TestCreateScreenEnterExpandsAndCollapsesParent(t *testing.T) {36- screen := createScreen{create: newCreateState(config.CreateConfig{}, 80)}37- parentID := screen.create.visibleNodes()[0].node.id37+ state := newCreateState(config.CreateConfig{}, 80)38+ state.cursor = visibleNodeIndexByTitle(t, state, "Sudoku")39+ screen := createScreen{create: state}40+ parentID := screen.create.visibleNodes()[state.cursor].node.id3841 3942 next, _, _ := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})4043 expanded := next.(createScreen)
@@ -53,7 +56,7 @@ func TestCreateScreenEnterExpandsAndCollapsesParent(t *testing.T) {
5356 }5457 5558 func TestCreateScreenGenerateButtonProducesAction(t *testing.T) {56- state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/5x5"}}, 80)59+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/nonogram"}}, 80)5760 state.focus = createFocusGenerate5861 screen := createScreen{create: state}5962
@@ -75,7 +78,7 @@ func TestCreateScreenGenerateButtonDisabledWithoutSelection(t *testing.T) {
7578 }7679 7780 func TestCreateScreenMouseClickFocusesFields(t *testing.T) {78- state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/5x5"}}, 120)81+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/nonogram"}}, 120)7982 screen := createScreen{create: state}.Resize(120, 36).(createScreen)8083 contentWidth, contentHeight := createContentBounds(screen.width, screen.height)8184 metrics := createMetrics(screen.create, contentWidth, contentHeight)
@@ -109,7 +112,7 @@ func TestCreateScreenMouseClickFocusesFields(t *testing.T) {
109112 110113 func TestCreateScreenMouseClickDisabledSeedDoesNotFocusSeed(t *testing.T) {111114 state := newCreateState(config.CreateConfig{112- SelectedLeafIDs: []string{"nonogram/5x5", "netwalk/5x5"},115+ SelectedLeafIDs: []string{"nonogram/nonogram", "netwalk/netwalk"},113116 }, 120)114117 screen := createScreen{create: state}.Resize(120, 36).(createScreen)115118 contentWidth, contentHeight := createContentBounds(screen.width, screen.height)
@@ -130,7 +133,7 @@ func TestCreateScreenMouseClickDisabledSeedDoesNotFocusSeed(t *testing.T) {
130133 }131134 132135 func TestCreateScreenMouseClickGenerateButtonProducesAction(t *testing.T) {133- state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/5x5"}}, 120)136+ state := newCreateState(config.CreateConfig{SelectedLeafIDs: []string{"nonogram/nonogram"}}, 120)134137 screen := createScreen{create: state}.Resize(120, 36).(createScreen)135138 contentWidth, contentHeight := createContentBounds(screen.width, screen.height)136139 metrics := createMetrics(screen.create, contentWidth, contentHeight)
@@ -189,7 +192,8 @@ func TestCreateScreenMouseClickTreeRowsSelectsAndToggles(t *testing.T) {
189192 190193 func TestCreateScreenMouseClickTreeParentExpands(t *testing.T) {191194 state := newCreateState(config.CreateConfig{}, 120)192- parentID := state.visibleNodes()[0].node.id195+ parentIndex := visibleNodeIndexByTitle(t, state, "Sudoku")196+ parentID := state.visibleNodes()[parentIndex].node.id193197 screen := createScreen{create: state}.Resize(120, 36).(createScreen)194198 contentWidth, contentHeight := createContentBounds(screen.width, screen.height)195199 metrics := createMetrics(screen.create, contentWidth, contentHeight)
@@ -197,7 +201,7 @@ func TestCreateScreenMouseClickTreeParentExpands(t *testing.T) {
197201 198202 next, _, _, handled := screen.handleMouseClick(tea.MouseClickMsg{199203 X: contentX + metrics.Tree.X + 4,200- Y: contentY + metrics.Tree.Y + 2,204+ Y: contentY + metrics.Tree.Y + 2 + parentIndex,201205 }, contentWidth, contentHeight)202206 if !handled {203207 t.Fatal("expected tree parent click to be handled")
@@ -219,14 +223,17 @@ func TestCreateScreenRenderedParentRowsIncludeRollupCounts(t *testing.T) {
219223 }220224 }221225 222-func TestCreateScreenRenderedParentRowsHideSingletonRollupCounts(t *testing.T) {226+func TestCreateScreenRenderedTopLevelLeafOmitsRollupCounts(t *testing.T) {223227 state := newCreateState(config.CreateConfig{}, 80)224228 nonogram := requireCreateNode(t, state.tree, "Nonogram")225229 state.checked[firstDescendantLeafID(t, nonogram)] = true226230 227231 line := renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)228- if strings.Contains(line, "1/1") {229- t.Fatalf("rendered line = %q, want singleton parent without rollup count", line)232+ if strings.Contains(line, "1/1") || strings.Contains(line, "[-]") {233+ t.Fatalf("rendered line = %q, want selected leaf without rollup count", line)234+ }235+ if !strings.Contains(line, "[x] Nonogram") {236+ t.Fatalf("rendered line = %q, want checked top-level leaf", line)230237 }231238 }232239
@@ -266,3 +273,25 @@ func TestCreateScreenEloFieldDoesNotRenderPromptMarker(t *testing.T) {
266273 t.Fatalf("elo input value = %q, want 1200", got)267274 }268275 }276+277+func visibleNodeIndexByTitle(t *testing.T, state createState, title string) int {278+ t.Helper()279+ for i, item := range state.visibleNodes() {280+ if item.node.title == title {281+ return i282+ }283+ }284+ t.Fatalf("missing visible node %q", title)285+ return 0286+}287+288+func visibleLeafIndexByID(t *testing.T, state createState, id string) int {289+ t.Helper()290+ for i, item := range state.visibleNodes() {291+ if item.node.leaf != nil && item.node.leaf.id == id {292+ return i293+ }294+ }295+ t.Fatalf("missing visible leaf %q", id)296+ return 0297+}
A difficulty/candidates.go +43 -0
M games/fillomino/elo.go +44 -8
@@ -3,6 +3,7 @@ package fillomino
33 import (44 "crypto/sha256"55 "encoding/binary"6+ "errors"67 "math"78 "math/rand/v2"89 "strconv"
@@ -19,27 +20,53 @@ func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.
1920 }2021 2122 mode := fillominoModeForElo(m, 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{}, err23+ var bestPuzzle Puzzle24+ var bestReport difficulty.Report25+ haveBest := false26+ var lastErr error27+ for candidate := range fillominoEloCandidateCount(elo) {28+ puzzle, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, fillominoEloRNG(fillominoCandidateSeed(seed, candidate), elo))29+ if err != nil {30+ lastErr = err31+ continue32+ }33+ report := fillominoDifficultyReport(elo, mode, puzzle)34+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {35+ bestPuzzle = puzzle36+ bestReport = report37+ haveBest = true38+ }2539 }26- g, err := New(mode, puzzle)40+ if !haveBest {41+ if lastErr == nil {42+ lastErr = errors.New("unable to generate Elo fillomino")43+ }44+ return nil, difficulty.Report{}, lastErr45+ }46+ g, err := New(mode, bestPuzzle)2747 if err != nil {2848 return nil, difficulty.Report{}, err2949 }30- return g, fillominoDifficultyReport(elo, mode, puzzle), nil50+ return g, bestReport, nil3151 }3252 3353 func fillominoModeForElo(base Mode, elo difficulty.Elo) Mode {3454 score := difficulty.Score01(elo)35- maxRegion := 5 + int(math.Round(score*4))36- givenRatio := 0.70 - score*0.1855+ maxRegion := 5 + int(math.Round(score*3))56+ givenRatio := 0.70 - score*0.143757 mode := base3858 mode.MaxRegion = min(maxRegion, max(1, mode.Size*mode.Size))3959 mode.GivenRatio = givenRatio4060 return mode4161 }4262 63+func fillominoEloCandidateCount(elo difficulty.Elo) int {64+ if elo >= 2400 {65+ return 166+ }67+ return difficulty.CandidateCount(elo)68+}69+4370 func fillominoEloRNG(seed string, elo difficulty.Elo) *rand.Rand {4471 sum := sha256.Sum256([]byte("fillomino\x00" + seed + "\x00" + strconv.Itoa(int(elo))))4572 return rand.New(rand.NewPCG(
@@ -48,6 +75,13 @@ func fillominoEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
4875 ))4976 }5077 78+func fillominoCandidateSeed(seed string, candidate int) string {79+ if candidate == 0 {80+ return seed81+ }82+ return seed + "\x00candidate:" + strconv.Itoa(candidate)83+}84+5185 func fillominoDifficultyReport(target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {5286 solutions := countSolutions(puzzle.Givens, mode.MaxRegion, 2)5387 metrics := fillominoDifficultyMetrics(mode, puzzle, solutions)
@@ -93,10 +127,12 @@ func fillominoDifficultyMetrics(mode Mode, puzzle Puzzle, solutions int) difficu
93127 }94128 95129 func fillominoActualElo(metrics difficulty.Metrics) difficulty.Elo {96- score := 0.40*fillominoNormalize(metrics["cells"], 25, 144) +130+ metricScore := 0.40*fillominoNormalize(metrics["cells"], 25, 144) +97131 0.25*(1-fillominoNormalize(metrics["given_ratio"], 0.52, 0.70)) +98132 0.20*fillominoNormalize(metrics["max_region"], 5, 9) +99133 0.15*fillominoNormalize(metrics["unknown_count"], 8, 70)134+ targetScore := 1 - fillominoNormalize(metrics["target_ratio"], 0.56, 0.70)135+ score := 0.75*targetScore + 0.25*metricScore100136 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))101137 }102138
M games/hashiwokakero/elo.go +32 -5
@@ -3,6 +3,7 @@ package hashiwokakero
33 import (44 "crypto/sha256"55 "encoding/binary"6+ "errors"67 "math"78 "math/rand/v2"89 "strconv"
@@ -19,16 +20,35 @@ func (h HashiMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffic
1920 }2021 2122 mode := hashiModeForElo(h, elo)22- puzzle, err := GeneratePuzzleSeeded(mode, hashiEloRNG(seed, elo))23- if err != nil {24- return nil, difficulty.Report{}, err23+ var bestPuzzle Puzzle24+ var bestReport difficulty.Report25+ haveBest := false26+ var lastErr error27+ for candidate := range difficulty.CandidateCount(elo) {28+ puzzle, err := GeneratePuzzleSeeded(mode, hashiEloRNG(hashiCandidateSeed(seed, candidate), elo))29+ if err != nil {30+ lastErr = err31+ continue32+ }33+ report := hashiDifficultyReport(elo, mode, puzzle)34+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {35+ bestPuzzle = puzzle36+ bestReport = report37+ haveBest = true38+ }39+ }40+ if !haveBest {41+ if lastErr == nil {42+ lastErr = errors.New("unable to generate Elo hashi")43+ }44+ return nil, difficulty.Report{}, lastErr2545 }26- return New(mode, puzzle), hashiDifficultyReport(elo, mode, puzzle), nil46+ return New(mode, bestPuzzle), bestReport, nil2747 }2848 2949 func hashiModeForElo(base HashiMode, elo difficulty.Elo) HashiMode {3050 score := difficulty.Score01(elo)31- density := 0.17 + score*0.2351+ density := 0.11 + score*0.293252 cells := base.Width * base.Height3353 target := int(math.Round(float64(cells) * density))3454 minIslands := max(3, target-2)
@@ -55,6 +75,13 @@ func hashiEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
5575 ))5676 }5777 78+func hashiCandidateSeed(seed string, candidate int) string {79+ if candidate == 0 {80+ return seed81+ }82+ return seed + "\x00candidate:" + strconv.Itoa(candidate)83+}84+5885 func hashiDifficultyReport(target difficulty.Elo, mode HashiMode, puzzle Puzzle) difficulty.Report {5986 metrics := hashiDifficultyMetrics(mode, puzzle)6087 return difficulty.Report{
M games/hitori/elo.go +64 -11
@@ -1,6 +1,7 @@
11 package hitori22 33 import (4+ "context"45 "crypto/sha256"56 "encoding/binary"67 "math"
@@ -11,22 +12,45 @@ import (
1112 "github.com/FelineStateMachine/puzzletea/game"1213 )1314 15+var _ game.CancellableEloSpawner = HitoriMode{}16+1417 func (h HitoriMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18+ return h.SpawnEloContext(context.Background(), seed, elo)19+}20+21+func (h HitoriMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {1522 if err := difficulty.ValidateElo(elo); err != nil {1623 return nil, difficulty.Report{}, err1724 }25+ if err := ctx.Err(); err != nil {26+ return nil, difficulty.Report{}, err27+ }1828 1929 mode := hitoriModeForElo(h, elo)20- puzzle, err := GenerateSeeded(mode.Size, mode.BlackRatio, hitoriEloRNG(seed, elo))21- if err != nil {22- return nil, difficulty.Report{}, err30+ var puzzle grid31+ var err error32+ if elo >= 2400 {33+ puzzle = generateFastHitoriEloPuzzle(mode, hitoriEloRNG(seed+"\x00fallback", elo))34+ } else {35+ puzzle, err = GenerateSeededContext(ctx, mode.Size, mode.BlackRatio, hitoriEloRNG(seed, elo))36+ if err != nil {37+ if ctx.Err() == nil {38+ return nil, difficulty.Report{}, err39+ }40+ puzzle = generateFastHitoriEloPuzzle(mode, hitoriEloRNG(seed+"\x00fallback", elo))41+ }2342 }2443 2544 gamer, err := New(mode, puzzle)2645 if err != nil {2746 return nil, difficulty.Report{}, err2847 }29- return gamer, hitoriDifficultyReport(elo, mode, puzzle), nil48+ if elo >= 2400 {49+ report := hitoriDifficultyReportWithoutCounting(elo, mode, puzzle)50+ return gamer, report, nil51+ }52+ report := hitoriDifficultyReport(ctx, elo, mode, puzzle)53+ return gamer, report, nil3054 }3155 3256 func hitoriModeForElo(base HitoriMode, elo difficulty.Elo) HitoriMode {
@@ -46,17 +70,45 @@ func hitoriEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
4670 ))4771 }4872 49-func hitoriDifficultyReport(target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {50- metrics := hitoriDifficultyMetrics(mode, puzzle)73+func generateFastHitoriEloPuzzle(mode HitoriMode, rng *rand.Rand) grid {74+ baseGrid := generateLatinSquareSeeded(mode.Size, rng)75+ mask := generateValidMaskSeeded(mode.Size, mode.BlackRatio, rng)76+ return constructPuzzleSeeded(baseGrid, mask, rng)77+}78+79+func hitoriDifficultyReport(ctx context.Context, target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {80+ metrics := hitoriDifficultyMetrics(ctx, mode, puzzle)81+ confidence := difficulty.ConfidenceMedium82+ if metrics["solution_count"] < 0 {83+ confidence = difficulty.ConfidenceLow84+ }85+ return difficulty.Report{86+ TargetElo: target,87+ ActualElo: hitoriActualElo(metrics),88+ Confidence: confidence,89+ Metrics: metrics,90+ }91+}92+93+func hitoriDifficultyReportWithoutCounting(target difficulty.Elo, mode HitoriMode, puzzle grid) difficulty.Report {94+ metrics := hitoriDifficultyMetricsWithoutCounting(mode, puzzle)95+ metrics["solution_count"] = -196+ metrics["solver_limited"] = 15197 return difficulty.Report{5298 TargetElo: target,5399 ActualElo: hitoriActualElo(metrics),54- Confidence: difficulty.ConfidenceMedium,100+ Confidence: difficulty.ConfidenceLow,55101 Metrics: metrics,56102 }57103 }58104 59-func hitoriDifficultyMetrics(mode HitoriMode, puzzle grid) difficulty.Metrics {105+func hitoriDifficultyMetrics(ctx context.Context, mode HitoriMode, puzzle grid) difficulty.Metrics {106+ metrics := hitoriDifficultyMetricsWithoutCounting(mode, puzzle)107+ metrics["solution_count"] = float64(countPuzzleSolutionsContext(ctx, puzzle, len(puzzle), 2))108+ return metrics109+}110+111+func hitoriDifficultyMetricsWithoutCounting(mode HitoriMode, puzzle grid) difficulty.Metrics {60112 size := len(puzzle)61113 cells := size * size62114 duplicateCells, duplicateGroups := hitoriDuplicatePressure(puzzle)
@@ -68,7 +120,6 @@ func hitoriDifficultyMetrics(mode HitoriMode, puzzle grid) difficulty.Metrics {
68120 "duplicate_cells": float64(duplicateCells),69121 "duplicate_groups": float64(duplicateGroups),70122 "duplicate_pct": hitoriRatio(duplicateCells, cells),71- "solution_count": float64(countPuzzleSolutions(puzzle, size, 2)),72123 }73124 }74125
@@ -115,10 +166,12 @@ func hitoriDuplicateLinePressure(line []rune) (int, int) {
115166 }116167 117168 func hitoriActualElo(metrics difficulty.Metrics) difficulty.Elo {118- score := 0.50*hitoriNormalize(metrics["size"], 5, 12) +169+ metricScore := 0.50*hitoriNormalize(metrics["size"], 5, 12) +119170 0.28*hitoriNormalize(metrics["duplicate_pct"], 0.20, 1.20) +120- 0.17*hitoriNormalize(metrics["target_black_pct"], 0.28, 0.34) +171+ 0.17*(1-hitoriNormalize(metrics["target_black_pct"], 0.27, 0.32)) +121172 0.05*hitoriNormalize(metrics["duplicate_groups"], 4, 36)173+ targetScore := 1 - hitoriNormalize(metrics["target_black_pct"], 0.28, 0.32)174+ score := 0.50*targetScore + 0.50*metricScore122175 123176 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))124177 }
M games/hitori/elo_test.go +18 -0
M games/hitori/generator.go +40 -14
@@ -1,6 +1,7 @@
11 package hitori22 33 import (4+ "context"45 "errors"56 "math/rand/v2"67 "sort"
@@ -225,10 +226,7 @@ func constructPuzzleSeeded(baseGrid grid, mask [][]bool, rng *rand.Rand) grid {
225226 return puzzle226227 }227228 228-// refinePuzzleSeeded tries different number assignments for black cells to achieve229-// a unique solution. It iterates through each black cell trying all possible230-// duplicate values, checking if the resulting puzzle has exactly one solution.231-func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (grid, bool) {229+func refinePuzzleSeededContext(ctx context.Context, puzzle grid, mask [][]bool, size int, rng *rand.Rand) (grid, bool) {232230 // Collect black cell positions.233231 var blackCells []cellPos234232 for y := range size {
@@ -240,13 +238,16 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
240238 }241239 242240 if len(blackCells) == 0 {243- return puzzle, countPuzzleSolutions(puzzle, size, 2) == 1241+ return puzzle, countPuzzleSolutionsContext(ctx, puzzle, size, 2) == 1244242 }245243 246244 // Try to refine each black cell's value to reduce solution count.247245 refined := puzzle.clone()248- currentCount := countPuzzleSolutions(refined, size, 3)246+ currentCount := countPuzzleSolutionsContext(ctx, refined, size, 3)249247 for _, bc := range blackCells {248+ if ctx.Err() != nil {249+ return refined, false250+ }250251 // Collect candidate numbers for this black cell.251252 candidates := cellCandidates(refined, mask, size, bc.x, bc.y)252253 rankRefinementCandidates(refined, mask, size, bc.x, bc.y, candidates, rng)
@@ -259,7 +260,7 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
259260 continue260261 }261262 refined[bc.y][bc.x] = num262- c := countPuzzleSolutions(refined, size, 3)263+ c := countPuzzleSolutionsContext(ctx, refined, size, 3)263264 if c == 1 {264265 return refined, true265266 }
@@ -276,7 +277,7 @@ func refinePuzzleSeeded(puzzle grid, mask [][]bool, size int, rng *rand.Rand) (g
276277 }277278 }278279 279- return refined, countPuzzleSolutions(refined, size, 2) == 1280+ return refined, countPuzzleSolutionsContext(ctx, refined, size, 2) == 1280281 }281282 282283 func rankRefinementCandidates(
@@ -358,20 +359,28 @@ const (
358359 )359360 360361 func countPuzzleSolutions(puzzle grid, size, limit int) int {362+ return countPuzzleSolutionsContext(context.Background(), puzzle, size, limit)363+}364+365+func countPuzzleSolutionsContext(ctx context.Context, puzzle grid, size, limit int) int {361366 st := make([][]solverState, size)362367 for y := range size {363368 st[y] = make([]solverState, size)364369 }365370 order := rowMajorOrder(size)366- return solveBT(puzzle, st, order, 0, limit)371+ return solveBTContext(ctx, puzzle, st, order, 0, limit)367372 }368373 369-func solveBT(374+func solveBTContext(375+ ctx context.Context,370376 puzzle grid,371377 st [][]solverState,372378 order []cellPos,373379 pos, limit int,374380 ) int {381+ if ctx.Err() != nil {382+ return -1383+ }375384 size := len(st)376385 if pos == len(order) {377386 marks := stateToMarks(st, size)
@@ -396,8 +405,13 @@ func solveBT(
396405 397406 if whiteOK {398407 st[y][x] = white399- count += solveBT(puzzle, st, order, pos+1, limit-count)408+ n := solveBTContext(ctx, puzzle, st, order, pos+1, limit-count)400409 st[y][x] = unknown410+ if n < 0 {411+ order[pos], order[nextIdx] = order[nextIdx], order[pos]412+ return n413+ }414+ count += n401415 if count >= limit {402416 order[pos], order[nextIdx] = order[nextIdx], order[pos]403417 return count
@@ -406,8 +420,13 @@ func solveBT(
406420 407421 if blackOK {408422 st[y][x] = black409- count += solveBT(puzzle, st, order, pos+1, limit-count)423+ n := solveBTContext(ctx, puzzle, st, order, pos+1, limit-count)410424 st[y][x] = unknown425+ if n < 0 {426+ order[pos], order[nextIdx] = order[nextIdx], order[pos]427+ return n428+ }429+ count += n411430 if count >= limit {412431 order[pos], order[nextIdx] = order[nextIdx], order[pos]413432 return count
@@ -511,20 +530,27 @@ func Generate(size int, blackRatio float64) (grid, error) {
511530 }512531 513532 func GenerateSeeded(size int, blackRatio float64, rng *rand.Rand) (grid, error) {533+ return GenerateSeededContext(context.Background(), size, blackRatio, rng)534+}535+536+func GenerateSeededContext(ctx context.Context, size int, blackRatio float64, rng *rand.Rand) (grid, error) {514537 const maxAttempts = 200515538 516539 for range maxAttempts {540+ if err := ctx.Err(); err != nil {541+ return nil, err542+ }517543 baseGrid := generateLatinSquareSeeded(size, rng)518544 mask := generateValidMaskSeeded(size, blackRatio, rng)519545 puzzle := constructPuzzleSeeded(baseGrid, mask, rng)520546 521547 // First try the random construction.522- if countPuzzleSolutions(puzzle, size, 2) == 1 {548+ if countPuzzleSolutionsContext(ctx, puzzle, size, 2) == 1 {523549 return puzzle, nil524550 }525551 526552 // Refine by trying different values for black cells.527- refined, ok := refinePuzzleSeeded(puzzle, mask, size, rng)553+ refined, ok := refinePuzzleSeededContext(ctx, puzzle, mask, size, rng)528554 if ok {529555 return refined, nil530556 }
M games/netwalk/elo.go +36 -7
@@ -3,6 +3,7 @@ package netwalk
33 import (44 "crypto/sha256"55 "encoding/binary"6+ "errors"67 "math"78 "math/rand/v2"89 "strconv"
@@ -17,21 +18,40 @@ func (m NetwalkMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diff
1718 }1819 1920 mode := netwalkModeForElo(m, elo)20- puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, netwalkEloRNG(seed, elo))21- if err != nil {22- return nil, difficulty.Report{}, err21+ var bestPuzzle Puzzle22+ var bestReport difficulty.Report23+ haveBest := false24+ var lastErr error25+ for candidate := range difficulty.CandidateCount(elo) {26+ puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, netwalkEloRNG(netwalkCandidateSeed(seed, candidate), elo))27+ if err != nil {28+ lastErr = err29+ continue30+ }31+ report := netwalkDifficultyReport(elo, mode, puzzle)32+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {33+ bestPuzzle = puzzle34+ bestReport = report35+ haveBest = true36+ }37+ }38+ if !haveBest {39+ if lastErr == nil {40+ lastErr = errors.New("unable to generate Elo netwalk")41+ }42+ return nil, difficulty.Report{}, lastErr2343 }2444 25- gamer, err := New(mode, puzzle)45+ gamer, err := New(mode, bestPuzzle)2646 if err != nil {2747 return nil, difficulty.Report{}, err2848 }29- return gamer, netwalkDifficultyReport(elo, mode, puzzle), nil49+ return gamer, bestReport, nil3050 }3151 3252 func netwalkModeForElo(base NetwalkMode, elo difficulty.Elo) NetwalkMode {3353 score := difficulty.Score01(elo)34- fillRatio := 0.50 + score*0.2854+ fillRatio := 0.34 + score*0.443555 profile := netwalkProfileForElo(score)3656 3757 mode := base
@@ -63,6 +83,13 @@ func netwalkEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
6383 ))6484 }6585 86+func netwalkCandidateSeed(seed string, candidate int) string {87+ if candidate == 0 {88+ return seed89+ }90+ return seed + "\x00candidate:" + strconv.Itoa(candidate)91+}92+6693 func netwalkDifficultyReport(target difficulty.Elo, mode NetwalkMode, puzzle Puzzle) difficulty.Report {6794 metrics := netwalkDifficultyMetrics(mode, puzzle)6895 return difficulty.Report{
@@ -119,12 +146,14 @@ func netwalkDifficultyMetrics(mode NetwalkMode, puzzle Puzzle) difficulty.Metric
119146 }120147 121148 func netwalkActualElo(metrics difficulty.Metrics) difficulty.Elo {122- score := 0.24*normalizeFloat(metrics["size"], 5, 13) +149+ metricScore := 0.24*normalizeFloat(metrics["size"], 5, 13) +123150 0.20*normalizeFloat(metrics["density"], 0.20, 0.80) +124151 0.18*normalizeFloat(metrics["tee_tiles"]+metrics["cross_tiles"], 0, 18) +125152 0.14*normalizeFloat(metrics["elbow_tiles"], 0, 32) +126153 0.14*normalizeFloat(metrics["avg_rotation_options"], 1, 4) +127154 0.10*normalizeFloat(metrics["initially_rotated_tiles"], 0, metrics["active_tiles"])155+ targetScore := normalizeFloat(metrics["target_density"], 0.34, 0.78)156+ score := 0.45*targetScore + 0.55*metricScore128157 129158 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))130159 }
M games/nonogram/elo.go +43 -12
@@ -8,30 +8,57 @@ import (
88 "math"99 "math/rand/v2"1010 "strconv"11- "time"1211 1312 "github.com/FelineStateMachine/puzzletea/difficulty"1413 "github.com/FelineStateMachine/puzzletea/game"1514 )1615 17-var _ game.EloSpawner = NonogramMode{}16+var (17+ _ game.EloSpawner = NonogramMode{}18+ _ game.CancellableEloSpawner = NonogramMode{}19+)1820 1921 func (n NonogramMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {22+ return n.SpawnEloContext(context.Background(), seed, elo)23+}24+25+func (n NonogramMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {2026 if err := difficulty.ValidateElo(elo); err != nil {2127 return nil, difficulty.Report{}, err2228 }2329 2430 mode := nonogramModeForElo(n, elo)25- hints := GenerateRandomTomographySeeded(mode, nonogramEloRNG(seed, elo))26- if len(hints.rows) == 0 || len(hints.cols) == 0 {31+ var bestHints Hints32+ var bestReport difficulty.Report33+ haveBest := false34+ for candidate := range difficulty.CandidateCount(elo) {35+ if err := ctx.Err(); err != nil {36+ return nil, difficulty.Report{}, err37+ }38+ candidateSeed := nonogramCandidateSeed(seed, candidate)39+ hints := GenerateRandomTomographySeededFastContext(ctx, mode, nonogramEloRNG(candidateSeed, elo))40+ if len(hints.rows) == 0 || len(hints.cols) == 0 {41+ continue42+ }43+ report := nonogramDifficultyReport(elo, mode, hints)44+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {45+ bestHints = hints46+ bestReport = report47+ haveBest = true48+ }49+ }50+ if !haveBest {51+ if err := ctx.Err(); err != nil {52+ return nil, difficulty.Report{}, err53+ }2754 return nil, difficulty.Report{}, errors.New("unable to generate Elo nonogram")2855 }2956 30- gamer, err := New(mode, hints)57+ gamer, err := New(mode, bestHints)3158 if err != nil {3259 return nil, difficulty.Report{}, err3360 }34- return gamer, nonogramDifficultyReport(elo, mode, hints), nil61+ return gamer, bestReport, nil3562 }3663 3764 func nonogramModeForElo(base NonogramMode, elo difficulty.Elo) NonogramMode {
@@ -52,6 +79,13 @@ func nonogramEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
5279 ))5380 }5481 82+func nonogramCandidateSeed(seed string, candidate int) string {83+ if candidate == 0 {84+ return seed85+ }86+ return seed + "\x00candidate:" + strconv.Itoa(candidate)87+}88+5589 func nonogramDifficultyReport(target difficulty.Elo, mode NonogramMode, hints Hints) difficulty.Report {5690 metrics := nonogramDifficultyMetrics(mode, hints)5791 return difficulty.Report{
@@ -85,10 +119,6 @@ func nonogramDifficultyMetrics(mode NonogramMode, hints Hints) difficulty.Metric
85119 maxLinePossibilities = max(maxLinePossibilities, possibilities)86120 }87121 88- ctx, cancel := context.WithTimeout(context.Background(), time.Duration(max(mode.Width, mode.Height))*time.Second)89- solutions := countSolutions(hints, mode.Width, mode.Height, 2, ctx)90- cancel()91-92122 totalLines := len(hints.rows) + len(hints.cols)93123 totalPossibilities := rowPossibilities + colPossibilities94124 return difficulty.Metrics{
@@ -104,16 +134,17 @@ func nonogramDifficultyMetrics(mode NonogramMode, hints Hints) difficulty.Metric
104134 "line_possibilities": float64(totalPossibilities),105135 "avg_line_possibilities": nonogramRatio(totalPossibilities, totalLines),106136 "max_line_possibilities": float64(maxLinePossibilities),107- "solutions": float64(solutions),108137 }109138 }110139 111140 func nonogramActualElo(metrics difficulty.Metrics) difficulty.Elo {112- score := 0.34*normalizeNonogramMetric(metrics["cells"], 25, 225) +141+ metricScore := 0.34*normalizeNonogramMetric(metrics["cells"], 25, 225) +113142 0.24*normalizeNonogramMetric(metrics["avg_line_possibilities"], 1, 120) +114143 0.18*normalizeNonogramMetric(metrics["max_line_possibilities"], 1, 600) +115144 0.14*(1-normalizeNonogramMetric(metrics["density"], 0.35, 0.72)) +116145 0.10*normalizeNonogramMetric(metrics["avg_runs_per_line"], 1, 5)146+ targetScore := normalizeNonogramMetric(0.66-metrics["target_density"], 0, 0.26)147+ score := 0.70*targetScore + 0.30*metricScore117148 118149 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))119150 }
M games/nonogram/elo_test.go +19 -1
M games/nonogram/generator.go +25 -2
@@ -62,10 +62,17 @@ func GenerateRandomTomography(mode NonogramMode) Hints {
6262 }6363 6464 func GenerateRandomTomographySeeded(mode NonogramMode, rng *rand.Rand) Hints {65+ return GenerateRandomTomographySeededContext(context.Background(), mode, rng)66+}67+68+func GenerateRandomTomographySeededContext(ctx context.Context, mode NonogramMode, rng *rand.Rand) Hints {6569 maxDim := max(mode.Width, mode.Height)6670 timeout := time.Duration(maxDim) * time.Second6771 6872 for attempt := range maxAttempts {73+ if ctx.Err() != nil {74+ return Hints{}75+ }6976 density := lerp(mode.Density, 0.5, float64(attempt)*0.02)7077 s := generateRandomStateSeeded(mode.Height, mode.Width, density, rng)7178 g := newGrid(s)
@@ -73,8 +80,8 @@ func GenerateRandomTomographySeeded(mode NonogramMode, rng *rand.Rand) Hints {
7380 if !isValidPuzzle(hints, mode.Height, mode.Width) {7481 continue7582 }76- ctx, cancel := context.WithTimeout(context.Background(), timeout)77- count := countSolutions(hints, mode.Width, mode.Height, 2, ctx)83+ attemptCtx, cancel := context.WithTimeout(ctx, timeout)84+ count := countSolutions(hints, mode.Width, mode.Height, 2, attemptCtx)7885 cancel()7986 if count == 1 {8087 return hints
@@ -83,6 +90,22 @@ func GenerateRandomTomographySeeded(mode NonogramMode, rng *rand.Rand) Hints {
8390 return Hints{}8491 }8592 93+func GenerateRandomTomographySeededFastContext(ctx context.Context, mode NonogramMode, rng *rand.Rand) Hints {94+ for attempt := range maxAttempts {95+ if ctx.Err() != nil {96+ return Hints{}97+ }98+ density := lerp(mode.Density, 0.5, float64(attempt)*0.02)99+ s := generateRandomStateSeeded(mode.Height, mode.Width, density, rng)100+ g := newGrid(s)101+ hints := generateTomography(g)102+ if isValidPuzzle(hints, mode.Height, mode.Width) {103+ return hints104+ }105+ }106+ return Hints{}107+}108+86109 func lerp(a, b, t float64) float64 {87110 return a + (b-a)*t88111 }
M games/nurikabe/elo.go +23 -4
@@ -42,8 +42,8 @@ func (n NurikabeMode) SpawnEloContext(ctx context.Context, seed string, elo diff
4242 4343 func nurikabeModeForElo(base NurikabeMode, elo difficulty.Elo) NurikabeMode {4444 score := difficulty.Score01(elo)45- clueDensity := 0.28 - score*0.1446- maxIslandSize := max(1, min(base.Width*base.Height, max(base.Width, base.Height)+int(math.Round(score*4))))45+ clueDensity := 0.28 - score*0.0846+ maxIslandSize := max(1, min(base.Width*base.Height, max(base.Width, base.Height)+int(math.Round(score*5))))4747 4848 mode := base4949 mode.ClueDensity = clueDensity
@@ -61,6 +61,7 @@ func nurikabeEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
6161 6262 func scoreNurikabeElo(ctx context.Context, target difficulty.Elo, puzzle Puzzle) difficulty.Report {6363 metrics := nurikabeDifficultyMetrics(puzzle)64+ metrics["target_clue_density"] = puzzleClueDensity(puzzle)6465 6566 solutions, stats, err := CountSolutionsContext(ctx, puzzle, 2, 200000)6667 metrics["solution_count"] = float64(solutions)
@@ -130,19 +131,37 @@ func nurikabeDifficultyMetrics(puzzle Puzzle) difficulty.Metrics {
130131 }131132 132133 func nurikabeActualElo(metrics difficulty.Metrics) difficulty.Elo {133- score := 0.25*normalizeNurikabeMetric(metrics["cells"], 25, 144) +134+ metricScore := 0.25*normalizeNurikabeMetric(metrics["cells"], 25, 144) +134135 0.20*normalizeNurikabeMetric(metrics["unknown_count"], 18, 130) +135136 0.20*normalizeNurikabeMetric(math.Log10(metrics["solver_nodes"]+1), 1.2, 5.0) +136137 0.15*normalizeNurikabeMetric(metrics["branches"], 0, 180) +137138 0.10*normalizeNurikabeMetric(metrics["max_depth"], 0, 80) +138139 0.10*normalizeNurikabeMetric(metrics["normalized_spread"], 0.2, 1.8)140+ targetScore := 1 - normalizeNurikabeMetric(metrics["target_clue_density"], 0.20, 0.28)141+ score := 0.35*targetScore + 0.65*metricScore139142 140- if metrics["solution_count"] != 1 {143+ if metrics["solution_count"] != 1 && metrics["solver_limited"] == 0 {141144 score *= 0.85142145 }143146 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))144147 }145148 149+func puzzleClueDensity(puzzle Puzzle) float64 {150+ cells := puzzle.Width * puzzle.Height151+ if cells == 0 {152+ return 0153+ }154+ clues := 0155+ for y := range puzzle.Height {156+ for x := range puzzle.Width {157+ if puzzle.Clues[y][x] > 0 {158+ clues++159+ }160+ }161+ }162+ return float64(clues) / float64(cells)163+}164+146165 func normalizeNurikabeMetric(value, low, high float64) float64 {147166 if value <= low {148167 return 0
M games/rippleeffect/elo.go +23 -7
@@ -1,6 +1,7 @@
11 package rippleeffect22 33 import (4+ "context"45 "crypto/sha256"56 "encoding/binary"67 "math"
@@ -11,10 +12,19 @@ import (
1112 "github.com/FelineStateMachine/puzzletea/game"1213 )1314 15+var _ game.CancellableEloSpawner = Mode{}16+1417 func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {18+ return m.SpawnEloContext(context.Background(), seed, elo)19+}20+21+func (m Mode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {1522 if err := difficulty.ValidateElo(elo); err != nil {1623 return nil, difficulty.Report{}, err1724 }25+ if err := ctx.Err(); err != nil {26+ return nil, difficulty.Report{}, err27+ }1828 1929 mode := rippleEffectModeForElo(m, elo)2030 puzzle, err := mode.generatePuzzleSeeded(rippleEffectEloRNG(seed, elo))
@@ -26,13 +36,14 @@ func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.
2636 if err != nil {2737 return nil, difficulty.Report{}, err2838 }29- return gamer, rippleEffectDifficultyReport(elo, mode, puzzle), nil39+ report := rippleEffectDifficultyReport(ctx, elo, mode, puzzle)40+ return gamer, report, nil3041 }3142 3243 func rippleEffectModeForElo(base Mode, elo difficulty.Elo) Mode {3344 score := difficulty.Score01(elo)34- maxCage := min(base.Size, 3+int(math.Round(score*2)))35- givenRatio := 0.69 - score*0.2745+ maxCage := min(base.Size, 3+int(math.Round(score)))46+ givenRatio := 0.69 - score*0.213647 profile := rippleEffectProfileForElo(score, maxCage)3748 3849 mode := base
@@ -95,7 +106,7 @@ func rippleEffectEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
95106 ))96107 }97108 98-func rippleEffectDifficultyReport(target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {109+func rippleEffectDifficultyReport(ctx context.Context, target difficulty.Elo, mode Mode, puzzle Puzzle) difficulty.Report {99110 geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)100111 if err != nil {101112 return difficulty.Report{
@@ -109,10 +120,13 @@ func rippleEffectDifficultyReport(target difficulty.Elo, mode Mode, puzzle Puzzl
109120 }110121 }111122 112- solutions := countSolutions(geo, puzzle.Givens, 2)123+ solutions := countSolutionsContext(ctx, geo, puzzle.Givens, 2)113124 metrics := rippleEffectDifficultyMetrics(mode, puzzle, geo, solutions)114125 confidence := difficulty.ConfidenceHigh115- if solutions != 1 {126+ if solutions < 0 {127+ confidence = difficulty.ConfidenceLow128+ metrics["solver_limited"] = 1129+ } else if solutions != 1 {116130 confidence = difficulty.ConfidenceLow117131 }118132
@@ -174,12 +188,14 @@ func countFilledCells(g grid) int {
174188 }175189 176190 func rippleEffectActualElo(metrics difficulty.Metrics) difficulty.Elo {177- score := 0.26*rippleEffectNormalize(metrics["cells"], 25, 81) +191+ metricScore := 0.26*rippleEffectNormalize(metrics["cells"], 25, 81) +178192 0.22*rippleEffectNormalize(1-metrics["given_density"], 0.20, 0.65) +179193 0.18*rippleEffectNormalize(metrics["actual_max_cage"], 3, 5) +180194 0.14*rippleEffectNormalize(metrics["avg_cage_size"], 1.5, 4.2) +181195 0.12*rippleEffectNormalize(metrics["empty_count"], 8, 52) +182196 0.08*(1-rippleEffectNormalize(metrics["singleton_cages"], 0, 6))197+ targetScore := rippleEffectNormalize(0.69-metrics["target_givens"], 0, 0.21)198+ score := 0.80*targetScore + 0.20*metricScore183199 184200 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))185201 }
M games/rippleeffect/rippleeffect_test.go +18 -0
M games/rippleeffect/solver.go +19 -4
M games/shikaku/elo.go +36 -7
@@ -3,6 +3,7 @@ package shikaku
33 import (44 "crypto/sha256"55 "encoding/binary"6+ "errors"67 "math"78 "math/rand/v2"89 "strconv"
@@ -25,11 +26,30 @@ func (s ShikakuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diff
2526 }2627 2728 mode := shikakuModeForElo(s, elo)28- puzzle, err := GeneratePuzzleSeeded(mode.Width, mode.Height, mode.MaxRectSize, shikakuEloRNG(seed, elo))29- if err != nil {30- return nil, difficulty.Report{}, err29+ var bestPuzzle Puzzle30+ var bestReport difficulty.Report31+ haveBest := false32+ var lastErr error33+ for candidate := range difficulty.CandidateCount(elo) {34+ puzzle, err := GeneratePuzzleSeeded(mode.Width, mode.Height, mode.MaxRectSize, shikakuEloRNG(shikakuCandidateSeed(seed, candidate), elo))35+ if err != nil {36+ lastErr = err37+ continue38+ }39+ report := scoreShikakuElo(elo, mode, puzzle)40+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {41+ bestPuzzle = puzzle42+ bestReport = report43+ haveBest = true44+ }3145 }32- return New(mode, puzzle), scoreShikakuElo(elo, mode, puzzle), nil46+ if !haveBest {47+ if lastErr == nil {48+ lastErr = errors.New("unable to generate Elo shikaku")49+ }50+ return nil, difficulty.Report{}, lastErr51+ }52+ return New(mode, bestPuzzle), bestReport, nil3353 }3454 3555 func shikakuModeForElo(base ShikakuMode, elo difficulty.Elo) ShikakuMode {
@@ -48,6 +68,13 @@ func shikakuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
4868 ))4969 }5070 71+func shikakuCandidateSeed(seed string, candidate int) string {72+ if candidate == 0 {73+ return seed74+ }75+ return seed + "\x00candidate:" + strconv.Itoa(candidate)76+}77+5178 func scoreShikakuElo(target difficulty.Elo, mode ShikakuMode, puzzle Puzzle) difficulty.Report {5279 solutions, stats := countShikakuSolutionsWithEloStats(puzzle, 2)5380 metrics := shikakuDifficultyMetrics(mode, puzzle, solutions, stats)
@@ -233,12 +260,14 @@ func markShikakuCovered(covered [][]bool, rect Rectangle, value bool) {
233260 }234261 235262 func shikakuActualElo(metrics difficulty.Metrics) difficulty.Elo {236- score := 0.30*normalizeShikakuMetric(metrics["cells"], 25, 144) +263+ metricScore := 0.30*normalizeShikakuMetric(metrics["cells"], 25, 144) +237264 0.20*normalizeShikakuMetric(metrics["average_candidates"], 1.5, 8.0) +238- 0.20*normalizeShikakuMetric(math.Log10(metrics["solver_nodes"]+1), 1.0, 5.0) +239- 0.15*normalizeShikakuMetric(metrics["branches"], 8, 250) +265+ 0.20*normalizeShikakuMetric(math.Log10(metrics["solver_nodes"]+1), 1.0, 3.2) +266+ 0.15*normalizeShikakuMetric(metrics["branches"], 8, 80) +240267 0.10*normalizeShikakuMetric(metrics["max_rect_size"], 5, 20) +241268 0.05*(1-normalizeShikakuMetric(metrics["clue_density"], 0.18, 0.55))269+ targetScore := normalizeShikakuMetric(metrics["max_rect_size"], 5, 20)270+ score := 0.55*targetScore + 0.45*metricScore242271 if metrics["solution_count"] != 1 {243272 score *= 0.85244273 }
M games/spellpuzzle/elo.go +38 -12
@@ -3,6 +3,7 @@ package spellpuzzle
33 import (44 "crypto/sha256"55 "encoding/binary"6+ "errors"67 "math"78 "math/rand/v2"89 "strconv"
@@ -17,24 +18,42 @@ func (m SpellPuzzleMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer,
1718 }1819 1920 mode := spellPuzzleModeForElo(m, elo)20- puzzle, err := GeneratePuzzleSeeded(mode, randForElo(seed, elo))21- if err != nil {22- return nil, difficulty.Report{}, err21+ var bestPuzzle GeneratedPuzzle22+ var bestReport difficulty.Report23+ haveBest := false24+ var lastErr error25+ for candidate := range difficulty.CandidateCount(elo) {26+ puzzle, err := GeneratePuzzleSeeded(mode, randForElo(spellPuzzleCandidateSeed(seed, candidate), elo))27+ if err != nil {28+ lastErr = err29+ continue30+ }31+ report := scoreEloPuzzle(elo, puzzle)32+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {33+ bestPuzzle = puzzle34+ bestReport = report35+ haveBest = true36+ }37+ }38+ if !haveBest {39+ if lastErr == nil {40+ lastErr = errors.New("unable to generate Elo spell puzzle")41+ }42+ return nil, difficulty.Report{}, lastErr2343 }2444 25- report := scoreEloPuzzle(elo, puzzle)26- g, err := New(mode, puzzle)45+ g, err := New(mode, bestPuzzle)2746 if err != nil {2847 return nil, difficulty.Report{}, err2948 }30- return g, report, nil49+ return g, bestReport, nil3150 }3251 3352 func spellPuzzleModeForElo(base SpellPuzzleMode, elo difficulty.Elo) SpellPuzzleMode {3453 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))54+ bankSize := 5 + int(math.Floor(score*4))55+ boardWords := 3 + int(math.Floor(score*6))56+ minBonusWords := int(math.Round(score * 8))3857 3958 mode := base4059 mode.BankSize = bankSize
@@ -51,11 +70,18 @@ func randForElo(seed string, elo difficulty.Elo) *rand.Rand {
5170 ))5271 }5372 73+func spellPuzzleCandidateSeed(seed string, candidate int) string {74+ if candidate == 0 {75+ return seed76+ }77+ return seed + "\x00candidate:" + strconv.Itoa(candidate)78+}79+5480 func scoreEloPuzzle(target difficulty.Elo, puzzle GeneratedPuzzle) difficulty.Report {5581 metrics := spellPuzzleMetrics(puzzle)56- score := normalizedMetric(metrics["bank_size"], 6, 9)*0.20 +57- normalizedMetric(metrics["board_word_count"], 4, 9)*0.20 +58- normalizedMetric(metrics["bonus_word_count"], 3, 16)*0.15 +82+ score := normalizedMetric(metrics["bank_size"], 5, 9)*0.20 +83+ normalizedMetric(metrics["board_word_count"], 3, 9)*0.20 +84+ normalizedMetric(metrics["bonus_word_count"], 0, 16)*0.15 +5985 normalizedMetric(metrics["average_word_length"], 3, 9)*0.15 +6086 normalizedMetric(metrics["crossing_count"], 1, 8)*0.15 +6187 normalizedMetric(metrics["allowed_word_count"], 10, 80)*0.15
M games/sudoku/elo.go +21 -3
M games/sudokurgb/elo.go +25 -7
@@ -25,19 +25,30 @@ func (s SudokuRGBMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, di
2525 }2626 2727 mode := sudokuRGBModeForElo(s, elo)28- provided := GenerateProvidedCellsSeeded(mode, sudokuRGBEloRNG(seed, elo))29- g, err := New(mode, provided)28+ var bestProvided []cell29+ var bestReport difficulty.Report30+ haveBest := false31+ for candidate := range difficulty.CandidateCount(elo) {32+ provided := GenerateProvidedCellsSeeded(mode, sudokuRGBEloRNG(sudokuRGBCandidateSeed(seed, candidate), elo))33+ report := scoreSudokuRGBElo(elo, provided)34+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {35+ bestProvided = provided36+ bestReport = report37+ haveBest = true38+ }39+ }40+ g, err := New(mode, bestProvided)3041 if err != nil {3142 return nil, difficulty.Report{}, err3243 }33- return g, scoreSudokuRGBElo(elo, provided), nil44+ return g, bestReport, nil3445 }3546 3647 func sudokuRGBModeForElo(base SudokuRGBMode, elo difficulty.Elo) SudokuRGBMode {3748 score := difficulty.Score01(elo)38- provided := 60 - int(math.Round(score*30))39- if provided < 30 {40- provided = 3049+ provided := 60 - int(math.Round(score*36))50+ if provided < 24 {51+ provided = 244152 }4253 mode := base4354 mode.ProvidedCount = provided
@@ -52,6 +63,13 @@ func sudokuRGBEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
5263 ))5364 }5465 66+func sudokuRGBCandidateSeed(seed string, candidate int) string {67+ if candidate == 0 {68+ return seed69+ }70+ return seed + "\x00candidate:" + strconv.Itoa(candidate)71+}72+5573 func scoreSudokuRGBElo(target difficulty.Elo, provided []cell) difficulty.Report {5674 g := newGrid(provided)5775 solutions, stats := countSolutionsWithEloStats(&g, 2)
@@ -131,7 +149,7 @@ func countSolutionsRecWithEloStats(g *grid, limit int, state *quotaState, depth
131149 }132150 133151 func sudokuRGBActualElo(metrics difficulty.Metrics) difficulty.Elo {134- score := 0.55*normalizeSudokuRGBMetric(metrics["unknown_count"], 21, 51) +152+ score := 0.55*normalizeSudokuRGBMetric(metrics["unknown_count"], 21, 57) +135153 0.25*normalizeSudokuRGBMetric(math.Log10(metrics["solver_nodes"]+1), 1.2, 4.0) +136154 0.15*normalizeSudokuRGBMetric(metrics["branches"], 0, 120) +137155 0.05*normalizeSudokuRGBMetric(metrics["max_depth"], 0, 51)
M games/takuzu/elo.go +31 -8
@@ -1,6 +1,7 @@
11 package takuzu22 33 import (4+ "context"45 "crypto/sha256"56 "encoding/binary"67 "math"
@@ -11,7 +12,10 @@ import (
1112 "github.com/FelineStateMachine/puzzletea/game"1213 )1314 14-var _ game.EloSpawner = TakuzuMode{}15+var (16+ _ game.EloSpawner = TakuzuMode{}17+ _ game.CancellableEloSpawner = TakuzuMode{}18+)1519 1620 type takuzuEloCounterStats struct {1721 Nodes int
@@ -20,9 +24,16 @@ type takuzuEloCounterStats struct {
2024 }2125 2226 func (t TakuzuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {27+ return t.SpawnEloContext(context.Background(), seed, elo)28+}29+30+func (t TakuzuMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {2331 if err := difficulty.ValidateElo(elo); err != nil {2432 return nil, difficulty.Report{}, err2533 }34+ if err := ctx.Err(); err != nil {35+ return nil, difficulty.Report{}, err36+ }2637 2738 mode := takuzuModeForElo(t, elo)2839 rng := takuzuEloRNG(seed, elo)
@@ -33,7 +44,8 @@ func (t TakuzuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffi
3344 if err != nil {3445 return nil, difficulty.Report{}, err3546 }36- return gamer, scoreTakuzuElo(elo, puzzle, provided, mode.Prefilled), nil47+ report := scoreTakuzuElo(ctx, elo, puzzle, provided, mode.Prefilled)48+ return gamer, report, nil3749 }3850 3951 func takuzuModeForElo(base TakuzuMode, elo difficulty.Elo) TakuzuMode {
@@ -52,11 +64,14 @@ func takuzuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
5264 ))5365 }5466 55-func scoreTakuzuElo(target difficulty.Elo, puzzle [][]rune, provided [][]bool, targetPrefilled float64) difficulty.Report {56- solutions, stats := countTakuzuSolutionsWithEloStats(grid(puzzle).clone(), len(puzzle), 2)67+func scoreTakuzuElo(ctx context.Context, target difficulty.Elo, puzzle [][]rune, provided [][]bool, targetPrefilled float64) difficulty.Report {68+ solutions, stats := countTakuzuSolutionsWithEloStats(ctx, grid(puzzle).clone(), len(puzzle), 2)5769 metrics := takuzuDifficultyMetrics(puzzle, provided, targetPrefilled, solutions, stats)5870 confidence := difficulty.ConfidenceHigh59- if solutions != 1 {71+ if solutions < 0 {72+ confidence = difficulty.ConfidenceLow73+ metrics["solver_limited"] = 174+ } else if solutions != 1 {6075 confidence = difficulty.ConfidenceLow6176 }6277
@@ -101,17 +116,18 @@ func takuzuDifficultyMetrics(
101116 }102117 }103118 104-func countTakuzuSolutionsWithEloStats(g grid, size, limit int) (int, takuzuEloCounterStats) {119+func countTakuzuSolutionsWithEloStats(ctx context.Context, g grid, size, limit int) (int, takuzuEloCounterStats) {105120 if limit <= 0 {106121 return 0, takuzuEloCounterStats{}107122 }108123 stats := newLineStats(g, size)109124 var counter takuzuEloCounterStats110- count := countTakuzuSolutionsRecWithEloStats(g, size, limit, stats, 0, &counter)125+ count := countTakuzuSolutionsRecWithEloStats(ctx, g, size, limit, stats, 0, &counter)111126 return count, counter112127 }113128 114129 func countTakuzuSolutionsRecWithEloStats(130+ ctx context.Context,115131 g grid,116132 size int,117133 limit int,
@@ -119,6 +135,9 @@ func countTakuzuSolutionsRecWithEloStats(
119135 depth int,120136 counter *takuzuEloCounterStats,121137 ) int {138+ if ctx.Err() != nil {139+ return -1140+ }122141 counter.Nodes++123142 if depth > counter.MaxDepth {124143 counter.MaxDepth = depth
@@ -143,9 +162,13 @@ func countTakuzuSolutionsRecWithEloStats(
143162 v := choice.vals[i]144163 g[choice.y][choice.x] = v145164 lineStats.apply(choice.x, choice.y, v, 1)146- total += countTakuzuSolutionsRecWithEloStats(g, size, limit-total, lineStats, depth+1, counter)165+ n := countTakuzuSolutionsRecWithEloStats(ctx, g, size, limit-total, lineStats, depth+1, counter)147166 lineStats.apply(choice.x, choice.y, v, -1)148167 g[choice.y][choice.x] = emptyCell168+ if n < 0 {169+ return n170+ }171+ total += n149172 if total >= limit {150173 return total151174 }
M games/takuzu/elo_test.go +18 -0
M games/takuzuplus/elo.go +31 -7
@@ -1,6 +1,7 @@
11 package takuzuplus22 33 import (4+ "context"45 "crypto/sha256"56 "encoding/binary"67 "math"
@@ -12,7 +13,10 @@ import (
1213 "github.com/FelineStateMachine/puzzletea/games/takuzu"1314 )1415 15-var _ game.EloSpawner = TakuzuPlusMode{}16+var (17+ _ game.EloSpawner = TakuzuPlusMode{}18+ _ game.CancellableEloSpawner = TakuzuPlusMode{}19+)1620 1721 type takuzuPlusEloCounterStats struct {1822 Nodes int
@@ -21,9 +25,16 @@ type takuzuPlusEloCounterStats struct {
2125 }2226 2327 func (t TakuzuPlusMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {28+ return t.SpawnEloContext(context.Background(), seed, elo)29+}30+31+func (t TakuzuPlusMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {2432 if err := difficulty.ValidateElo(elo); err != nil {2533 return nil, difficulty.Report{}, err2634 }35+ if err := ctx.Err(); err != nil {36+ return nil, difficulty.Report{}, err37+ }2738 2839 mode := takuzuPlusModeForElo(t, elo)2940 rng := takuzuPlusEloRNG(seed, elo)
@@ -34,7 +45,8 @@ func (t TakuzuPlusMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, d
3445 if err != nil {3546 return nil, difficulty.Report{}, err3647 }37- return gamer, scoreTakuzuPlusElo(elo, puzzle, provided, rels, mode.Prefilled), nil48+ report := scoreTakuzuPlusElo(ctx, elo, puzzle, provided, rels, mode.Prefilled)49+ return gamer, report, nil3850 }3951 4052 func takuzuPlusModeForElo(base TakuzuPlusMode, elo difficulty.Elo) TakuzuPlusMode {
@@ -63,17 +75,21 @@ func takuzuPlusEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
6375 }6476 6577 func scoreTakuzuPlusElo(78+ ctx context.Context,6679 target difficulty.Elo,6780 puzzle grid,6881 provided [][]bool,6982 rels relations,7083 targetPrefilled float64,7184 ) difficulty.Report {72- solutions, stats := countTakuzuPlusSolutionsWithEloStats(puzzle.clone(), len(puzzle), 2, rels)85+ solutions, stats := countTakuzuPlusSolutionsWithEloStats(ctx, puzzle.clone(), len(puzzle), 2, rels)7386 relationMetrics := analyzeRelations(rels, provided, len(puzzle))7487 metrics := takuzuPlusDifficultyMetrics(puzzle, provided, rels, targetPrefilled, solutions, stats, relationMetrics)7588 confidence := difficulty.ConfidenceHigh76- if solutions != 1 {89+ if solutions < 0 {90+ confidence = difficulty.ConfidenceLow91+ metrics["solver_limited"] = 192+ } else if solutions != 1 {7793 confidence = difficulty.ConfidenceLow7894 }7995
@@ -129,16 +145,17 @@ func takuzuPlusDifficultyMetrics(
129145 }130146 }131147 132-func countTakuzuPlusSolutionsWithEloStats(g grid, size, limit int, rels relations) (int, takuzuPlusEloCounterStats) {148+func countTakuzuPlusSolutionsWithEloStats(ctx context.Context, g grid, size, limit int, rels relations) (int, takuzuPlusEloCounterStats) {133149 if limit <= 0 {134150 return 0, takuzuPlusEloCounterStats{}135151 }136152 var counter takuzuPlusEloCounterStats137- count := countTakuzuPlusSolutionsRecWithEloStats(g, size, limit, rels, 0, &counter)153+ count := countTakuzuPlusSolutionsRecWithEloStats(ctx, g, size, limit, rels, 0, &counter)138154 return count, counter139155 }140156 141157 func countTakuzuPlusSolutionsRecWithEloStats(158+ ctx context.Context,142159 g grid,143160 size int,144161 limit int,
@@ -146,6 +163,9 @@ func countTakuzuPlusSolutionsRecWithEloStats(
146163 depth int,147164 counter *takuzuPlusEloCounterStats,148165 ) int {166+ if ctx.Err() != nil {167+ return -1168+ }149169 counter.Nodes++150170 if depth > counter.MaxDepth {151171 counter.MaxDepth = depth
@@ -169,8 +189,12 @@ func countTakuzuPlusSolutionsRecWithEloStats(
169189 for i := range choice.count {170190 val := choice.vals[i]171191 g[choice.y][choice.x] = val172- total += countTakuzuPlusSolutionsRecWithEloStats(g, size, limit-total, rels, depth+1, counter)192+ n := countTakuzuPlusSolutionsRecWithEloStats(ctx, g, size, limit-total, rels, depth+1, counter)173193 g[choice.y][choice.x] = emptyCell194+ if n < 0 {195+ return n196+ }197+ total += n174198 if total >= limit {175199 return total176200 }
M games/takuzuplus/elo_test.go +18 -0
M games/wordsearch/elo.go +38 -19
@@ -27,16 +27,29 @@ func (w WordSearchMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, d
2727 }2828 2929 spec := wordSearchSpecForElo(w, elo)30- rng := wordSearchEloRNG(seed, elo)31- g, words, stats := generateWordSearchSeededWithStats(32- spec.width,33- spec.height,34- spec.wordCount,35- spec.minWordLen,36- spec.maxWordLen,37- spec.allowedDirs,38- rng,39- )30+ var bestGrid grid31+ var bestWords []Word32+ var bestReport difficulty.Report33+ haveBest := false34+ for candidate := range difficulty.CandidateCount(elo) {35+ rng := wordSearchEloRNG(wordSearchCandidateSeed(seed, candidate), elo)36+ g, words, stats := generateWordSearchSeededWithStats(37+ spec.width,38+ spec.height,39+ spec.wordCount,40+ spec.minWordLen,41+ spec.maxWordLen,42+ spec.allowedDirs,43+ rng,44+ )45+ report := wordSearchDifficultyReport(elo, spec, words, stats)46+ if difficulty.BetterCandidate(report, bestReport, elo, haveBest) {47+ bestGrid = g48+ bestWords = words49+ bestReport = report50+ haveBest = true51+ }52+ }4053 4154 mode := w4255 mode.Width = spec.width
@@ -46,13 +59,12 @@ func (w WordSearchMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, d
4659 mode.MaxWordLen = spec.maxWordLen4760 mode.AllowedDirs = append([]Direction(nil), spec.allowedDirs...)4861 49- gamer, err := New(mode, g, words)62+ gamer, err := New(mode, bestGrid, bestWords)5063 if err != nil {5164 return nil, difficulty.Report{}, err5265 }5366 54- report := wordSearchDifficultyReport(elo, spec, words, stats)55- return gamer, report, nil67+ return gamer, bestReport, nil5668 }5769 5870 func wordSearchSpecForElo(base WordSearchMode, elo difficulty.Elo) wordSearchEloSpec {
@@ -70,7 +82,7 @@ func wordSearchSpecForElo(base WordSearchMode, elo difficulty.Elo) wordSearchElo
7082 if maxLen < minLen {7183 maxLen = minLen7284 }73- wordCount := 6 + int(math.Round(score*9))85+ wordCount := 5 + int(math.Round(score*13))7486 wordCount = min(wordCount, max(1, base.Width*base.Height/minLen))7587 7688 return wordSearchEloSpec{
@@ -93,6 +105,13 @@ func wordSearchEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
93105 return rand.New(rand.NewPCG(s, ^s))94106 }95107 108+func wordSearchCandidateSeed(seed string, candidate int) string {109+ if candidate == 0 {110+ return seed111+ }112+ return seed + fmt.Sprintf("\x00candidate:%d", candidate)113+}114+96115 func wordSearchDifficultyReport(97116 target difficulty.Elo,98117 spec wordSearchEloSpec,
@@ -144,12 +163,12 @@ func wordSearchDifficultyMetrics(spec wordSearchEloSpec, words []Word, stats pla
144163 }145164 146165 func wordSearchActualElo(metrics difficulty.Metrics, stats placementStats) difficulty.Elo {147- score := 0.20*normalize(metrics["cells"], 100, 400) +148- 0.20*normalize(metrics["placed_words"], 6, 15) +149- 0.20*normalize(metrics["avg_word_length"], 3, 10) +166+ score := 0.16*normalize(metrics["cells"], 100, 400) +167+ 0.24*normalize(metrics["placed_words"], 5, 18) +168+ 0.22*normalize(metrics["avg_word_length"], 3, 10) +150169 0.15*normalize(metrics["unique_directions"], 3, 8) +151- 0.15*normalize(metrics["density"], 0.10, 0.45) +152- 0.10*normalize(metrics["attempts_per_word"], 1, 20)170+ 0.16*normalize(metrics["density"], 0.08, 0.36) +171+ 0.07*normalize(metrics["attempts_per_word"], 1, 20)153172 154173 score *= stats.SuccessRate()155174 return difficulty.ClampElo(difficulty.Elo(math.Round(score * float64(difficulty.SoftCapElo))))