- commit
- 8f1fb6606286a192e6927252b2739a71e88a1b8d
- parent
- ac55f10ff9a85fe0ba2e1d27a45cbc14bcc126ce
- author
- Dami <dami.etoile@gmail.com>
@@ -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+}@@ -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+")@@ -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+}@@ -0,0 +1,43 @@
1+package difficulty2+3+import "math"4+5+func CandidateCount(elo Elo) int {6+ score := Score01(elo)7+ if score <= 0 {8+ return 19+ }10+ return min(8, 1+int(math.Round(score*7)))11+}12+13+func BetterCandidate(candidate, best Report, target Elo, hasBest bool) bool {14+ if !hasBest {15+ return true16+ }17+ candidateDelta := absInt(int(candidate.ActualElo) - int(target))18+ bestDelta := absInt(int(best.ActualElo) - int(target))19+ if candidateDelta != bestDelta {20+ return candidateDelta < bestDelta21+ }22+ return confidenceRank(candidate.Confidence) > confidenceRank(best.Confidence)23+}24+25+func absInt(v int) int {26+ if v < 0 {27+ return -v28+ }29+ return v30+}31+32+func confidenceRank(confidence Confidence) int {33+ switch confidence {34+ case ConfidenceHigh:35+ return 336+ case ConfidenceMedium:37+ return 238+ case ConfidenceLow:39+ return 140+ default:41+ return 042+ }43+}@@ -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 @@ -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{@@ -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 }@@ -1,6 +1,7 @@
11 package hitori22 33 import (4+ "context"45 "reflect"56 "testing"67 @@ -22,6 +23,23 @@ func TestSpawnEloRejectsInvalidElo(t *testing.T) {
2223 }2324 }2425 26+func TestSpawnEloContextCanceled(t *testing.T) {27+ mode := NewMode("Test", "Test mode.", 5, 0.32)28+ ctx, cancel := context.WithCancel(context.Background())29+ cancel()30+31+ gamer, report, err := mode.SpawnEloContext(ctx, "seed", 1200)32+ if err == nil {33+ t.Fatal("SpawnEloContext returned nil error for canceled context")34+ }35+ if gamer != nil {36+ t.Fatalf("gamer = %#v, want nil", gamer)37+ }38+ if !reflect.DeepEqual(report, difficulty.Report{}) {39+ t.Fatalf("report = %#v, want zero report", report)40+ }41+}42+2543 func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {2644 mode := NewMode("Test", "Test mode.", 5, 0.32)2745 target := difficulty.Elo(1500)@@ -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 }@@ -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 }@@ -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 }@@ -1,6 +1,7 @@
11 package nonogram22 33 import (4+ "context"45 "reflect"56 "testing"67 @@ -24,6 +25,23 @@ func TestSpawnEloRejectsInvalidElo(t *testing.T) {
2425 }2526 }2627 28+func TestSpawnEloContextCanceled(t *testing.T) {29+ mode := NewMode("Elo", "test", 5, 5, 0.5)30+ ctx, cancel := context.WithCancel(context.Background())31+ cancel()32+33+ gamer, report, err := mode.SpawnEloContext(ctx, "seed", 1200)34+ if err == nil {35+ t.Fatal("SpawnEloContext returned nil error for canceled context")36+ }37+ if gamer != nil {38+ t.Fatalf("gamer = %#v, want nil", gamer)39+ }40+ if !reflect.DeepEqual(report, difficulty.Report{}) {41+ t.Fatalf("report = %#v, want zero report", report)42+ }43+}44+2745 func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {2846 mode := NewMode("Elo", "test", 5, 5, 0.5)2947 const target = difficulty.Elo(1200)@@ -82,9 +100,9 @@ func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {
82100 "density": 0,83101 "target_density": 0,84102 "total_runs": 1,103+ "line_possibilities": 1,85104 "avg_line_possibilities": 1,86105 "max_line_possibilities": 1,87- "solutions": 1,88106 }89107 for key, min := range requiredMetrics {90108 got, ok := report.Metrics[key]@@ -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 }@@ -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@@ -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 }@@ -1,6 +1,7 @@
11 package rippleeffect22 33 import (4+ "context"45 "fmt"56 "image"67 "image/color"@@ -24,6 +25,23 @@ func sampleCages() []Cage {
2425 }2526 }2627 28+func TestSpawnEloContextCanceled(t *testing.T) {29+ mode := NewMode("Elo", "test", 5, 3, 0.6)30+ ctx, cancel := context.WithCancel(context.Background())31+ cancel()32+33+ gamer, report, err := mode.SpawnEloContext(ctx, "seed", 1200)34+ if err == nil {35+ t.Fatal("SpawnEloContext returned nil error for canceled context")36+ }37+ if gamer != nil {38+ t.Fatalf("gamer = %#v, want nil", gamer)39+ }40+ if !reflect.DeepEqual(report, difficulty.Report{}) {41+ t.Fatalf("report = %#v, want zero report", report)42+ }43+}44+2745 func sampleSolution() grid {2846 return grid{2947 {1, 2, 3},@@ -1,6 +1,9 @@
11 package rippleeffect22 3-import "math"3+import (4+ "context"5+ "math"6+)47 58 type validationResult struct {69 solved bool@@ -78,14 +81,21 @@ func hasConflicts(conflicts [][]bool) bool {
7881 }7982 8083 func countSolutions(geo *geometry, givens grid, limit int) int {84+ return countSolutionsContext(context.Background(), geo, givens, limit)85+}86+87+func countSolutionsContext(ctx context.Context, geo *geometry, givens grid, limit int) int {8188 working := cloneGrid(givens)82- return searchSolutions(geo, working, limit)89+ return searchSolutionsContext(ctx, geo, working, limit)8390 }8491 85-func searchSolutions(geo *geometry, state grid, limit int) int {92+func searchSolutionsContext(ctx context.Context, geo *geometry, state grid, limit int) int {8693 if limit <= 0 {8794 return 08895 }96+ if ctx.Err() != nil {97+ return -198+ }8999 90100 cell, candidates, ok := chooseNextCell(geo, state)91101 if !ok {@@ -101,7 +111,12 @@ func searchSolutions(geo *geometry, state grid, limit int) int {
101111 total := 0102112 for _, value := range candidates {103113 state[cell.y][cell.x] = value104- total += searchSolutions(geo, state, limit-total)114+ n := searchSolutionsContext(ctx, geo, state, limit-total)115+ if n < 0 {116+ state[cell.y][cell.x] = 0117+ return n118+ }119+ total += n105120 if total >= limit {106121 state[cell.y][cell.x] = 0107122 return total@@ -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 }@@ -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@@ -25,12 +25,23 @@ func (s SudokuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffi
2525 }2626 2727 mode := sudokuModeForElo(s, elo)28- provided := GenerateProvidedCellsSeeded(mode, sudokuEloRNG(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, sudokuEloRNG(sudokuCandidateSeed(seed, candidate), elo))33+ report := scoreSudokuElo(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, scoreSudokuElo(elo, provided), nil44+ return g, bestReport, nil3445 }3546 3647 func sudokuModeForElo(base SudokuMode, elo difficulty.Elo) SudokuMode {@@ -52,6 +63,13 @@ func sudokuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {
5263 ))5364 }5465 66+func sudokuCandidateSeed(seed string, candidate int) string {67+ if candidate == 0 {68+ return seed69+ }70+ return seed + "\x00candidate:" + strconv.Itoa(candidate)71+}72+5573 func scoreSudokuElo(target difficulty.Elo, provided []cell) difficulty.Report {5674 g := newGrid(provided)5775 solutions, stats := countSolutionsWithEloStats(&g, 2)@@ -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)@@ -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 }@@ -1,6 +1,7 @@
11 package takuzu22 33 import (4+ "context"45 "fmt"56 "reflect"67 "testing"@@ -31,6 +32,23 @@ func TestSpawnEloRejectsInvalidElo(t *testing.T) {
3132 }3233 }3334 35+func TestSpawnEloContextCanceled(t *testing.T) {36+ mode := NewMode("Elo", "test", 8, 0.4)37+ ctx, cancel := context.WithCancel(context.Background())38+ cancel()39+40+ gamer, report, err := mode.SpawnEloContext(ctx, "seed", 1200)41+ if err == nil {42+ t.Fatal("SpawnEloContext returned nil error for canceled context")43+ }44+ if gamer != nil {45+ t.Fatalf("gamer = %#v, want nil", gamer)46+ }47+ if !reflect.DeepEqual(report, difficulty.Report{}) {48+ t.Fatalf("report = %#v, want zero report", report)49+ }50+}51+3452 func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {3553 mode := NewMode("Elo", "test", 8, 0.4)3654 const seed = "takuzu-deterministic"@@ -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 }@@ -1,6 +1,7 @@
11 package takuzuplus22 33 import (4+ "context"45 "reflect"56 "testing"67 @@ -28,6 +29,23 @@ func TestSpawnEloRejectsInvalidElo(t *testing.T) {
2829 }2930 }3031 32+func TestSpawnEloContextCanceled(t *testing.T) {33+ mode := NewMode("Elo", "test", 8, 0.4, modeProfiles[1])34+ ctx, cancel := context.WithCancel(context.Background())35+ cancel()36+37+ gamer, report, err := mode.SpawnEloContext(ctx, "seed", 1200)38+ if err == nil {39+ t.Fatal("SpawnEloContext returned nil error for canceled context")40+ }41+ if gamer != nil {42+ t.Fatalf("gamer = %#v, want nil", gamer)43+ }44+ if !reflect.DeepEqual(report, difficulty.Report{}) {45+ t.Fatalf("report = %#v, want zero report", report)46+ }47+}48+3149 func TestSpawnEloDeterministicForSameSeedAndElo(t *testing.T) {3250 mode := NewMode("Elo", "test", 8, 0.4, modeProfiles[1])3351 const elo = difficulty.Elo(900)@@ -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))))