- commit
- b28424a4feee29ba9f20b1dca3cf87e490c85732
- parent
- 4455328a2b0b1e6728d1c4e92f8628165e972986
- author
- Dami <dami.etoile@gmail.com>
| M | cmd/ | 25 | ++++-- |
| M | cmd/ | 30 | +++++-- |
| M | cmd/ | 63 | +++++++++++++++ |
| A | cmd/ | 122 | ++++++++++++++++++++++++++++++ |
| M | games/ | 10 | +- |
| M | games/ | 25 | +++--- |
| M | games/ | 14 | +++ |
| M | games/ | 6 | - |
| M | games/ | 10 | +-- |
| M | games/ | 16 | ++++ |
| M | games/ | 16 | +-- |
| M | games/ | 21 | +---- |
| M | games/ | 17 | ++++ |
| M | games/ | 21 | ++--- |
| M | games/ | 20 | ++--- |
| M | games/ | 9 | +- |
| M | games/ | 12 | ++- |
| M | games/ | 8 | +- |
| M | games/ | 8 | +- |
| M | games/ | 14 | +-- |
| M | games/ | 16 | +-- |
| M | games/ | 15 | ++- |
| M | resolve/ | 10 | -- |
| M | resolve/ | 18 | ++-- |
@@ -83,11 +83,6 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
8383 return err8484 }8585 86- g, report, err := spawnFromMode(mode, seed, targetElo)87- if err != nil {88- return fmt.Errorf("failed to spawn game: %w", err)89- }90-9186 s, err := openStoreFn(cfg.DBPath)9287 if err != nil {9388 return err@@ -95,6 +90,11 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
9590 defer s.Close()9691 9792 name := sessionflow.GenerateUniqueName(s)93+94+ g, report, err := spawnFromMode(mode, seed, targetElo, name)95+ if err != nil {96+ return fmt.Errorf("failed to spawn game: %w", err)97+ }9898 g = g.SetTitle(name)9999 100100 meta := difficultyMetadataFromReport(report)@@ -106,12 +106,21 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
106106 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)107107 }108108 109-func spawnFromMode(mode registry.ModeEntry, seed string, targetElo *difficulty.Elo) (game.Gamer, difficulty.Report, error) {110- if targetElo != nil {109+func spawnFromMode(mode registry.ModeEntry, seed string, targetElo *difficulty.Elo, fallbackEloSeed string) (game.Gamer, difficulty.Report, error) {110+ effectiveElo := targetElo111+ if effectiveElo == nil {112+ effectiveElo = mode.Definition.PresetElo113+ }114+115+ if effectiveElo != nil {111116 if mode.Elo == nil {112117 return nil, difficulty.Report{}, fmt.Errorf("mode does not support Elo difficulty")113118 }114- g, report, err := mode.Elo.SpawnElo(seed, *targetElo)119+ eloSeed := seed120+ if eloSeed == "" {121+ eloSeed = fallbackEloSeed122+ }123+ g, report, err := mode.Elo.SpawnElo(eloSeed, *effectiveElo)115124 if err != nil {116125 return nil, difficulty.Report{}, err117126 }@@ -23,9 +23,10 @@ import (
2323 var exportNow = time.Now2424 2525 type exportModeEntry struct {26- spawner game.Spawner27- elo game.EloSpawner28- mode string26+ spawner game.Spawner27+ elo game.EloSpawner28+ presetElo *difficulty.Elo29+ mode string2930 }3031 3132 func runNewExport(cmd *cobra.Command, args []string) error {@@ -97,15 +98,21 @@ func collectExportModes(entry registry.Entry, modeArg string) ([]exportModeEntry
9798 if err != nil {9899 return nil, "", err99100 }100- return []exportModeEntry{{spawner: mode.Spawner, elo: mode.Elo, mode: mode.Definition.Title}}, mode.Definition.Title, nil101+ return []exportModeEntry{{102+ spawner: mode.Spawner,103+ elo: mode.Elo,104+ presetElo: mode.Definition.PresetElo,105+ mode: mode.Definition.Title,106+ }}, mode.Definition.Title, nil101107 }102108 103109 entries := make([]exportModeEntry, 0, len(entry.Modes))104110 for _, mode := range entry.Modes {105111 entries = append(entries, exportModeEntry{106- spawner: mode.Spawner,107- elo: mode.Elo,108- mode: mode.Definition.Title,112+ spawner: mode.Spawner,113+ elo: mode.Elo,114+ presetElo: mode.Definition.PresetElo,115+ mode: mode.Definition.Title,109116 })110117 }111118 if len(entries) == 0 {@@ -196,11 +203,16 @@ func scopedExportEloSeed(seed string, generatedAt time.Time, gameType, mode stri
196203 }197204 198205 func spawnExportPuzzle(entry exportModeEntry, rng *rand.Rand, seed string, targetElo *difficulty.Elo) (game.Gamer, difficulty.Report, error) {199- if targetElo != nil {206+ effectiveElo := targetElo207+ if effectiveElo == nil {208+ effectiveElo = entry.presetElo209+ }210+211+ if effectiveElo != nil {200212 if entry.elo == nil {201213 return nil, difficulty.Report{}, fmt.Errorf("mode does not support Elo difficulty")202214 }203- g, report, err := entry.elo.SpawnElo(seed, *targetElo)215+ g, report, err := entry.elo.SpawnElo(seed, *effectiveElo)204216 if err != nil {205217 return nil, difficulty.Report{}, err206218 }@@ -187,6 +187,54 @@ func TestRunNewExportOmitsPrintPayload(t *testing.T) {
187187 }188188 }189189 190+func TestRunNewExportUsesPresetEloMetadata(t *testing.T) {191+ withExportFlagReset(t)192+ flagExport = 1193+194+ cmd, out := newExportTestCmd(t, true)195+ if err := runNewExport(cmd, []string{"sudoku", "easy"}); err != nil {196+ t.Fatalf("expected sudoku export success, got error: %v", err)197+ }198+199+ record := decodeSingleExportRecord(t, out.String())200+ if record.Puzzle.TargetDifficultyElo == nil {201+ t.Fatal("expected target_difficulty_elo from preset")202+ }203+ if got, want := *record.Puzzle.TargetDifficultyElo, 600; got != want {204+ t.Fatalf("target_difficulty_elo = %d, want %d", got, want)205+ }206+ if record.Puzzle.ActualDifficultyElo == nil {207+ t.Fatal("expected actual_difficulty_elo from Elo report")208+ }209+ if record.Puzzle.DifficultyConfidence == "" {210+ t.Fatal("expected difficulty_confidence")211+ }212+}213+214+func TestRunNewExportExplicitDifficultyOverridesPreset(t *testing.T) {215+ withExportFlagReset(t)216+ flagExport = 1217+ flagDifficulty = 2100218+219+ cmd, out := newExportTestCmd(t, true)220+ cmd.Flags().Int("difficulty", -1, "")221+ if err := cmd.Flags().Set("difficulty", strconv.Itoa(flagDifficulty)); err != nil {222+ t.Fatal(err)223+ }224+225+ if err := runNewExport(cmd, []string{"sudoku", "easy"}); err != nil {226+ t.Fatalf("expected sudoku export success, got error: %v", err)227+ }228+229+ record := decodeSingleExportRecord(t, out.String())230+ if record.Puzzle.TargetDifficultyElo == nil {231+ t.Fatal("expected target_difficulty_elo from explicit difficulty")232+ }233+ if got, want := *record.Puzzle.TargetDifficultyElo, flagDifficulty; got != want {234+ t.Fatalf("target_difficulty_elo = %d, want %d", got, want)235+ }236+}237+190238 func withExportFlagReset(t *testing.T) {191239 t.Helper()192240 @@ -214,6 +262,21 @@ func withExportFlagReset(t *testing.T) {
214262 })215263 }216264 265+func decodeSingleExportRecord(t *testing.T, output string) pdfexport.JSONLRecord {266+ t.Helper()267+268+ lines := strings.Split(strings.TrimSpace(output), "\n")269+ if len(lines) != 1 {270+ t.Fatalf("jsonl lines = %d, want 1", len(lines))271+ }272+273+ var record pdfexport.JSONLRecord274+ if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {275+ t.Fatal(err)276+ }277+ return record278+}279+217280 func newExportTestCmd(t *testing.T, exportChanged bool) (*cobra.Command, *bytes.Buffer) {218281 t.Helper()219282 @@ -0,0 +1,122 @@
1+package cmd2+3+import (4+ "math/rand/v2"5+ "testing"6+7+ "charm.land/bubbles/v2/key"8+ tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/puzzletea/difficulty"10+ "github.com/FelineStateMachine/puzzletea/game"11+ "github.com/FelineStateMachine/puzzletea/puzzle"12+ "github.com/FelineStateMachine/puzzletea/registry"13+)14+15+type testSpawnGamer struct{}16+17+func (testSpawnGamer) GetDebugInfo() string { return "" }18+func (testSpawnGamer) GetFullHelp() [][]key.Binding { return nil }19+func (testSpawnGamer) GetSave() ([]byte, error) { return []byte(`{}`), nil }20+func (testSpawnGamer) IsSolved() bool { return false }21+func (testSpawnGamer) Reset() game.Gamer { return testSpawnGamer{} }22+func (testSpawnGamer) SetTitle(string) game.Gamer { return testSpawnGamer{} }23+func (testSpawnGamer) Init() tea.Cmd { return nil }24+func (testSpawnGamer) View() string { return "" }25+func (testSpawnGamer) Update(tea.Msg) (game.Gamer, tea.Cmd) { return testSpawnGamer{}, nil }26+27+type testLegacySpawner struct {28+ spawned bool29+ seeded bool30+ seededCalled bool31+}32+33+func (s *testLegacySpawner) Spawn() (game.Gamer, error) {34+ s.spawned = true35+ return testSpawnGamer{}, nil36+}37+38+func (s *testLegacySpawner) SpawnSeeded(*rand.Rand) (game.Gamer, error) {39+ s.seeded = true40+ s.seededCalled = true41+ return testSpawnGamer{}, nil42+}43+44+type testEloSpawner struct {45+ testLegacySpawner46+ called bool47+ seed string48+ elo difficulty.Elo49+}50+51+func (s *testEloSpawner) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {52+ s.called = true53+ s.seed = seed54+ s.elo = elo55+ return testSpawnGamer{}, difficulty.Report{56+ TargetElo: elo,57+ ActualElo: elo,58+ Confidence: difficulty.ConfidenceHigh,59+ }, nil60+}61+62+func TestSpawnFromModeUsesPresetEloWhenDifficultyOmitted(t *testing.T) {63+ preset := difficulty.Elo(1200)64+ spawner := &testEloSpawner{}65+ mode := registry.ModeEntry{66+ Definition: puzzle.ModeDef{Title: "Preset", PresetElo: &preset},67+ Spawner: spawner,68+ Elo: spawner,69+ }70+71+ _, report, err := spawnFromMode(mode, "", nil, "generated-name")72+ if err != nil {73+ t.Fatalf("spawnFromMode returned error: %v", err)74+ }75+ if !spawner.called {76+ t.Fatal("expected SpawnElo to be called")77+ }78+ if spawner.seed != "generated-name" {79+ t.Fatalf("Elo seed = %q, want generated save name", spawner.seed)80+ }81+ if spawner.elo != preset || report.TargetElo != preset || report.Confidence == "" {82+ t.Fatalf("Elo/report = (%d, %#v), want preset report", spawner.elo, report)83+ }84+}85+86+func TestSpawnFromModeExplicitDifficultyOverridesPreset(t *testing.T) {87+ preset := difficulty.Elo(800)88+ explicit := difficulty.Elo(2200)89+ spawner := &testEloSpawner{}90+ mode := registry.ModeEntry{91+ Definition: puzzle.ModeDef{Title: "Preset", PresetElo: &preset},92+ Spawner: spawner,93+ Elo: spawner,94+ }95+96+ _, _, err := spawnFromMode(mode, "user-seed", &explicit, "generated-name")97+ if err != nil {98+ t.Fatalf("spawnFromMode returned error: %v", err)99+ }100+ if spawner.elo != explicit {101+ t.Fatalf("Elo = %d, want explicit %d", spawner.elo, explicit)102+ }103+ if spawner.seed != "user-seed" {104+ t.Fatalf("seed = %q, want explicit seed", spawner.seed)105+ }106+}107+108+func TestSpawnFromModeFallsBackWithoutElo(t *testing.T) {109+ spawner := &testLegacySpawner{}110+ mode := registry.ModeEntry{111+ Definition: puzzle.ModeDef{Title: "Legacy"},112+ Spawner: spawner,113+ Seeded: spawner,114+ }115+116+ if _, _, err := spawnFromMode(mode, "", nil, "generated-name"); err != nil {117+ t.Fatalf("random spawnFromMode returned error: %v", err)118+ }119+ if !spawner.spawned {120+ t.Fatal("expected legacy Spawn to be called")121+ }122+}@@ -18,7 +18,7 @@ func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.
1818 return nil, difficulty.Report{}, err1919 }2020 21- mode := fillominoModeForElo(elo)21+ mode := fillominoModeForElo(m, elo)2222 puzzle, err := GeneratePuzzleSeeded(mode.Size, mode.Size, mode.MaxRegion, mode.GivenRatio, fillominoEloRNG(seed, elo))2323 if err != nil {2424 return nil, difficulty.Report{}, err@@ -30,12 +30,14 @@ func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.
3030 return g, fillominoDifficultyReport(elo, mode, puzzle), nil3131 }3232 33-func fillominoModeForElo(elo difficulty.Elo) Mode {33+func fillominoModeForElo(base Mode, elo difficulty.Elo) Mode {3434 score := difficulty.Score01(elo)35- size := 5 + int(math.Round(score*7))3635 maxRegion := 5 + int(math.Round(score*4))3736 givenRatio := 0.70 - score*0.1838- return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted Fillomino puzzle.", size, maxRegion, givenRatio)37+ mode := base38+ mode.MaxRegion = min(maxRegion, max(1, mode.Size*mode.Size))39+ mode.GivenRatio = givenRatio40+ return mode3941 }4042 4143 func fillominoEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -18,7 +18,7 @@ func (h HashiMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffic
1818 return nil, difficulty.Report{}, err1919 }2020 21- mode := hashiModeForElo(elo)21+ mode := hashiModeForElo(h, elo)2222 puzzle, err := GeneratePuzzleSeeded(mode, hashiEloRNG(seed, elo))2323 if err != nil {2424 return nil, difficulty.Report{}, err@@ -26,22 +26,21 @@ func (h HashiMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffic
2626 return New(mode, puzzle), hashiDifficultyReport(elo, mode, puzzle), nil2727 }2828 29-func hashiModeForElo(elo difficulty.Elo) HashiMode {29+func hashiModeForElo(base HashiMode, elo difficulty.Elo) HashiMode {3030 score := difficulty.Score01(elo)31- size := 7 + 2*int(math.Round(score*3))3231 density := 0.17 + score*0.2333- target := int(math.Round(float64(size*size) * density))32+ cells := base.Width * base.Height33+ target := int(math.Round(float64(cells) * density))3434 minIslands := max(3, target-2)35- maxIslands := min(size*size, target+2)35+ maxIslands := min(cells, target+2)36+ if minIslands > maxIslands {37+ minIslands = maxIslands38+ }3639 37- return NewMode(38- "Elo "+strconv.Itoa(int(elo)),39- "Elo-targeted Hashiwokakero puzzle.",40- size,41- size,42- minIslands,43- maxIslands,44- )40+ mode := base41+ mode.MinIslands = minIslands42+ mode.MaxIslands = maxIslands43+ return mode4544 }4645 4746 func hashiEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -104,6 +104,20 @@ func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {
104104 }105105 }106106 107+func TestSpawnEloPreservesSelectedDimensions(t *testing.T) {108+ mode := NewMode("Easy 7x7", "7x7 grid with 8-10 islands.", 7, 7, 8, 10)109+ _, report, err := mode.SpawnElo("fixed-size", 2800)110+ if err != nil {111+ t.Fatalf("SpawnElo returned error: %v", err)112+ }113+ if got, want := report.Metrics["width"], 7.0; got != want {114+ t.Fatalf("width metric = %.0f, want %.0f", got, want)115+ }116+ if got, want := report.Metrics["height"], 7.0; got != want {117+ t.Fatalf("height metric = %.0f, want %.0f", got, want)118+ }119+}120+107121 func TestModeDefinitionsIncludePresetElo(t *testing.T) {108122 if len(ModeDefinitions) != len(Modes) {109123 t.Fatalf("ModeDefinitions length = %d, want %d", len(ModeDefinitions), len(Modes))@@ -31,15 +31,9 @@ func (h HitoriMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffi
3131 3232 func hitoriModeForElo(base HitoriMode, elo difficulty.Elo) HitoriMode {3333 score := difficulty.Score01(elo)34- size := 5 + int(math.Round(score*7))3534 blackRatio := 0.32 - score*0.043635 3736 mode := base38- mode.BaseMode = game.NewBaseMode(39- "Elo "+strconv.Itoa(int(elo)),40- "Elo-targeted Hitori puzzle.",41- )42- mode.Size = size4337 mode.BlackRatio = blackRatio4438 return mode4539 }@@ -23,22 +23,18 @@ func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.
2323 return nil, difficulty.Report{}, err2424 }2525 26- spec := lightsOutSpecForElo(elo)26+ spec := lightsOutSpecForMode(m)2727 rng := lightsOutRandForElo(seed, elo)2828 grid, report := generateLightsOutEloGrid(spec, rng, elo)2929 3030 mode := m31- mode.Width = spec.width32- mode.Height = spec.height3331 3432 g := newFromGrid(mode.Width, mode.Height, grid)3533 return g, report, nil3634 }3735 38-func lightsOutSpecForElo(elo difficulty.Elo) eloSpec {39- score := difficulty.Score01(elo)40- size := 3 + 2*int(math.Round(score*3))41- return eloSpec{width: size, height: size}36+func lightsOutSpecForMode(mode Mode) eloSpec {37+ return eloSpec{width: mode.Width, height: mode.Height}4238 }4339 4440 func lightsOutRandForElo(seed string, elo difficulty.Elo) *rand.Rand {@@ -103,3 +103,19 @@ func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {
103103 }104104 }105105 }106+107+func TestSpawnEloPreservesSelectedDimensions(t *testing.T) {108+ mode := NewMode("Easy", "3x3 grid", 3, 3)109+ for _, elo := range []difficulty.Elo{100, 2800} {110+ _, report, err := mode.SpawnElo("fixed-size", elo)111+ if err != nil {112+ t.Fatalf("SpawnElo(%d) returned error: %v", elo, err)113+ }114+ if got, want := report.Metrics["width"], 3.0; got != want {115+ t.Fatalf("SpawnElo(%d) width metric = %.0f, want %.0f", elo, got, want)116+ }117+ if got, want := report.Metrics["height"], 3.0; got != want {118+ t.Fatalf("SpawnElo(%d) height metric = %.0f, want %.0f", elo, got, want)119+ }120+ }121+}@@ -16,7 +16,7 @@ func (m NetwalkMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diff
1616 return nil, difficulty.Report{}, err1717 }1818 19- mode := netwalkModeForElo(elo)19+ mode := netwalkModeForElo(m, elo)2020 puzzle, err := GenerateSeededWithDensity(mode.Size, mode.FillRatio, mode.Profile, netwalkEloRNG(seed, elo))2121 if err != nil {2222 return nil, difficulty.Report{}, err@@ -29,19 +29,15 @@ func (m NetwalkMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diff
2929 return gamer, netwalkDifficultyReport(elo, mode, puzzle), nil3030 }3131 32-func netwalkModeForElo(elo difficulty.Elo) NetwalkMode {32+func netwalkModeForElo(base NetwalkMode, elo difficulty.Elo) NetwalkMode {3333 score := difficulty.Score01(elo)34- size := 5 + int(math.Round(score*4))*23534 fillRatio := 0.50 + score*0.283635 profile := netwalkProfileForElo(score)3736 38- return NewMode(39- "Elo "+strconv.Itoa(int(elo)),40- "Elo-targeted Netwalk puzzle.",41- size,42- fillRatio,43- profile,44- )37+ mode := base38+ mode.FillRatio = fillRatio39+ mode.Profile = profile40+ return mode4541 }4642 4743 func netwalkProfileForElo(score float64) generateProfile {@@ -21,7 +21,7 @@ func (n NonogramMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, dif
2121 return nil, difficulty.Report{}, err2222 }2323 24- mode := nonogramModeForElo(elo)24+ mode := nonogramModeForElo(n, elo)2525 hints := GenerateRandomTomographySeeded(mode, nonogramEloRNG(seed, elo))2626 if len(hints.rows) == 0 || len(hints.cols) == 0 {2727 return nil, difficulty.Report{}, errors.New("unable to generate Elo nonogram")@@ -34,25 +34,14 @@ func (n NonogramMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, dif
3434 return gamer, nonogramDifficultyReport(elo, mode, hints), nil3535 }3636 37-func nonogramModeForElo(elo difficulty.Elo) NonogramMode {37+func nonogramModeForElo(base NonogramMode, elo difficulty.Elo) NonogramMode {3838 score := difficulty.Score01(elo)3939 40- size := 541- switch {42- case score >= 0.75:43- size = 1544- case score >= 0.35:45- size = 1046- }4740 density := 0.66 - score*0.264841 49- return NewMode(50- "Elo "+strconv.Itoa(int(elo)),51- "Elo-targeted Nonogram puzzle.",52- size,53- size,54- density,55- )42+ mode := base43+ mode.Density = density44+ return mode5645 }5746 5847 func nonogramEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -99,3 +99,20 @@ func TestSpawnEloPopulatesDifficultyReport(t *testing.T) {
9999 t.Fatalf("density = %v, want <= 1", report.Metrics["density"])100100 }101101 }102+103+func TestSpawnEloPreservesSelectedDimensions(t *testing.T) {104+ mode := NewMode("Mini", "5x5 grid, ~65% filled. Quick puzzle, straightforward hints.", 5, 5, 0.65)105+ _, report, err := mode.SpawnElo("fixed-size", 2600)106+ if err != nil {107+ t.Fatalf("SpawnElo returned error: %v", err)108+ }109+ if got, want := report.Metrics["width"], 5.0; got != want {110+ t.Fatalf("width metric = %.0f, want %.0f", got, want)111+ }112+ if got, want := report.Metrics["height"], 5.0; got != want {113+ t.Fatalf("height metric = %.0f, want %.0f", got, want)114+ }115+ if got := report.Metrics["target_density"]; got == 0.65 {116+ t.Fatalf("target_density = %.2f, want Elo-tuned density", got)117+ }118+}@@ -27,7 +27,7 @@ func (n NurikabeMode) SpawnEloContext(ctx context.Context, seed string, elo diff
2727 return nil, difficulty.Report{}, err2828 }2929 30- mode := nurikabeModeForElo(elo)30+ mode := nurikabeModeForElo(n, elo)3131 puzzle, err := GenerateSeededWithContext(ctx, mode, nurikabeEloRNG(seed, elo))3232 if err != nil {3333 return nil, difficulty.Report{}, err@@ -40,20 +40,15 @@ func (n NurikabeMode) SpawnEloContext(ctx context.Context, seed string, elo diff
4040 return gamer, scoreNurikabeElo(ctx, elo, puzzle), nil4141 }4242 43-func nurikabeModeForElo(elo difficulty.Elo) NurikabeMode {43+func nurikabeModeForElo(base NurikabeMode, elo difficulty.Elo) NurikabeMode {4444 score := difficulty.Score01(elo)45- size := 5 + int(math.Round(score*7))4645 clueDensity := 0.28 - score*0.1447- maxIslandSize := size + int(math.Round(score*4))48-49- return NewMode(50- "Elo "+strconv.Itoa(int(elo)),51- "Elo-targeted Nurikabe puzzle.",52- size,53- size,54- clueDensity,55- maxIslandSize,56- )46+ maxIslandSize := max(1, min(base.Width*base.Height, max(base.Width, base.Height)+int(math.Round(score*4))))47+48+ mode := base49+ mode.ClueDensity = clueDensity50+ mode.MaxIslandSize = maxIslandSize51+ return mode5752 }5853 5954 func nurikabeEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -16,7 +16,7 @@ func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.
1616 return nil, difficulty.Report{}, err1717 }1818 19- mode := rippleEffectModeForElo(elo)19+ mode := rippleEffectModeForElo(m, elo)2020 puzzle, err := mode.generatePuzzleSeeded(rippleEffectEloRNG(seed, elo))2121 if err != nil {2222 return nil, difficulty.Report{}, err@@ -29,21 +29,17 @@ func (m Mode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.
2929 return gamer, rippleEffectDifficultyReport(elo, mode, puzzle), nil3030 }3131 32-func rippleEffectModeForElo(elo difficulty.Elo) Mode {32+func rippleEffectModeForElo(base Mode, elo difficulty.Elo) Mode {3333 score := difficulty.Score01(elo)34- size := 5 + int(math.Round(score*4))35- maxCage := 3 + int(math.Round(score*2))34+ maxCage := min(base.Size, 3+int(math.Round(score*2)))3635 givenRatio := 0.69 - score*0.273736 profile := rippleEffectProfileForElo(score, maxCage)3837 39- return NewModeWithProfile(40- "Elo "+strconv.Itoa(int(elo)),41- "Elo-targeted Ripple Effect puzzle.",42- size,43- maxCage,44- givenRatio,45- profile,46- )38+ mode := base39+ mode.MaxCage = maxCage40+ mode.GivenRatio = givenRatio41+ mode.profile = profile42+ return mode4743 }4844 4945 func rippleEffectProfileForElo(score float64, maxCage int) generationProfile {@@ -24,7 +24,7 @@ func (s ShikakuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diff
2424 return nil, difficulty.Report{}, err2525 }2626 27- mode := shikakuModeForElo(elo)27+ mode := shikakuModeForElo(s, elo)2828 puzzle, err := GeneratePuzzleSeeded(mode.Width, mode.Height, mode.MaxRectSize, shikakuEloRNG(seed, elo))2929 if err != nil {3030 return nil, difficulty.Report{}, err@@ -32,11 +32,12 @@ func (s ShikakuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diff
3232 return New(mode, puzzle), scoreShikakuElo(elo, mode, puzzle), nil3333 }3434 35-func shikakuModeForElo(elo difficulty.Elo) ShikakuMode {35+func shikakuModeForElo(base ShikakuMode, elo difficulty.Elo) ShikakuMode {3636 score := difficulty.Score01(elo)37- size := 5 + int(math.Round(score*7))3837 maxRectSize := 5 + int(math.Round(score*15))39- return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted Shikaku puzzle.", size, size, maxRectSize)38+ mode := base39+ mode.MaxRectSize = min(maxRectSize, max(1, mode.Width*mode.Height))40+ return mode4041 }4142 4243 func shikakuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -11,12 +11,12 @@ import (
1111 "github.com/FelineStateMachine/puzzletea/game"1212 )1313 14-func (SpellPuzzleMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {14+func (m SpellPuzzleMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {1515 if err := difficulty.ValidateElo(elo); err != nil {1616 return nil, difficulty.Report{}, err1717 }1818 19- mode := spellPuzzleModeForElo(elo)19+ mode := spellPuzzleModeForElo(m, elo)2020 puzzle, err := GeneratePuzzleSeeded(mode, randForElo(seed, elo))2121 if err != nil {2222 return nil, difficulty.Report{}, err@@ -30,13 +30,17 @@ func (SpellPuzzleMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, di
3030 return g, report, nil3131 }3232 33-func spellPuzzleModeForElo(elo difficulty.Elo) SpellPuzzleMode {33+func spellPuzzleModeForElo(base SpellPuzzleMode, elo difficulty.Elo) SpellPuzzleMode {3434 score := difficulty.Score01(elo)3535 bankSize := 6 + int(math.Round(score*3))3636 boardWords := 4 + int(math.Round(score*5))3737 minBonusWords := 3 + int(math.Round(score*5))3838 39- return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted spell puzzle.", bankSize, boardWords, minBonusWords)39+ mode := base40+ mode.BankSize = bankSize41+ mode.BoardWordCount = boardWords42+ mode.MinBonusWords = minBonusWords43+ return mode4044 }4145 4246 func randForElo(seed string, elo difficulty.Elo) *rand.Rand {@@ -24,7 +24,7 @@ func (s SudokuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffi
2424 return nil, difficulty.Report{}, err2525 }2626 27- mode := sudokuModeForElo(elo)27+ mode := sudokuModeForElo(s, elo)2828 provided := GenerateProvidedCellsSeeded(mode, sudokuEloRNG(seed, elo))2929 g, err := New(mode, provided)3030 if err != nil {@@ -33,13 +33,15 @@ func (s SudokuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffi
3333 return g, scoreSudokuElo(elo, provided), nil3434 }3535 36-func sudokuModeForElo(elo difficulty.Elo) SudokuMode {36+func sudokuModeForElo(base SudokuMode, elo difficulty.Elo) SudokuMode {3737 score := difficulty.Score01(elo)3838 provided := 45 - int(math.Round(score*28))3939 if provided < 17 {4040 provided = 174141 }42- return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted sudoku.", provided)42+ mode := base43+ mode.ProvidedCount = provided44+ return mode4345 }4446 4547 func sudokuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -24,7 +24,7 @@ func (s SudokuRGBMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, di
2424 return nil, difficulty.Report{}, err2525 }2626 27- mode := sudokuRGBModeForElo(elo)27+ mode := sudokuRGBModeForElo(s, elo)2828 provided := GenerateProvidedCellsSeeded(mode, sudokuRGBEloRNG(seed, elo))2929 g, err := New(mode, provided)3030 if err != nil {@@ -33,13 +33,15 @@ func (s SudokuRGBMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, di
3333 return g, scoreSudokuRGBElo(elo, provided), nil3434 }3535 36-func sudokuRGBModeForElo(elo difficulty.Elo) SudokuRGBMode {36+func sudokuRGBModeForElo(base SudokuRGBMode, elo difficulty.Elo) SudokuRGBMode {3737 score := difficulty.Score01(elo)3838 provided := 60 - int(math.Round(score*30))3939 if provided < 30 {4040 provided = 304141 }42- return NewMode("Elo "+strconv.Itoa(int(elo)), "Elo-targeted sudoku RGB.", provided)42+ mode := base43+ mode.ProvidedCount = provided44+ return mode4345 }4446 4547 func sudokuRGBEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -24,7 +24,7 @@ func (t TakuzuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffi
2424 return nil, difficulty.Report{}, err2525 }2626 27- mode := takuzuModeForElo(elo)27+ mode := takuzuModeForElo(t, elo)2828 rng := takuzuEloRNG(seed, elo)2929 complete := GenerateCompleteGridSeeded(mode.Size, rng)3030 puzzle, provided := GeneratePuzzleFromCompleteSeeded(complete, mode.Size, mode.Prefilled, rng)@@ -36,16 +36,12 @@ func (t TakuzuMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, diffi
3636 return gamer, scoreTakuzuElo(elo, puzzle, provided, mode.Prefilled), nil3737 }3838 39-func takuzuModeForElo(elo difficulty.Elo) TakuzuMode {39+func takuzuModeForElo(base TakuzuMode, elo difficulty.Elo) TakuzuMode {4040 score := difficulty.Score01(elo)41- size := 6 + 2*int(math.Round(score*4))4241 prefilled := 0.52 - score*0.2443- return NewMode(44- "Elo "+strconv.Itoa(int(elo)),45- "Elo-targeted Takuzu puzzle.",46- size,47- prefilled,48- )42+ mode := base43+ mode.Prefilled = prefilled44+ return mode4945 }5046 5147 func takuzuEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -25,7 +25,7 @@ func (t TakuzuPlusMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, d
2525 return nil, difficulty.Report{}, err2626 }2727 28- mode := takuzuPlusModeForElo(elo)28+ mode := takuzuPlusModeForElo(t, elo)2929 rng := takuzuPlusEloRNG(seed, elo)3030 complete := generateCompleteSeeded(mode.Size, rng)3131 puzzle, provided, rels := generatePuzzleSeeded(complete, mode.Size, mode.Prefilled, mode.profile, rng)@@ -37,9 +37,8 @@ func (t TakuzuPlusMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, d
3737 return gamer, scoreTakuzuPlusElo(elo, puzzle, provided, rels, mode.Prefilled), nil3838 }3939 40-func takuzuPlusModeForElo(elo difficulty.Elo) TakuzuPlusMode {40+func takuzuPlusModeForElo(base TakuzuPlusMode, elo difficulty.Elo) TakuzuPlusMode {4141 score := difficulty.Score01(elo)42- size := 6 + 2*int(math.Round(score*4))4342 prefilled := 0.52 - score*0.244443 profileIndex := int(math.Round(score * float64(len(modeProfiles)-1)))4544 if profileIndex < 0 {@@ -49,13 +48,10 @@ func takuzuPlusModeForElo(elo difficulty.Elo) TakuzuPlusMode {
4948 profileIndex = len(modeProfiles) - 15049 }5150 52- return NewMode(53- "Elo "+strconv.Itoa(int(elo)),54- "Elo-targeted Takuzu+ puzzle.",55- size,56- prefilled,57- modeProfiles[profileIndex],58- )51+ mode := base52+ mode.Prefilled = prefilled53+ mode.profile = modeProfiles[profileIndex]54+ return mode5955 }6056 6157 func takuzuPlusEloRNG(seed string, elo difficulty.Elo) *rand.Rand {@@ -26,7 +26,7 @@ func (w WordSearchMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, d
2626 return nil, difficulty.Report{}, err2727 }2828 29- spec := wordSearchSpecForElo(elo)29+ spec := wordSearchSpecForElo(w, elo)3030 rng := wordSearchEloRNG(seed, elo)3131 g, words, stats := generateWordSearchSeededWithStats(3232 spec.width,@@ -55,7 +55,7 @@ func (w WordSearchMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, d
5555 return gamer, report, nil5656 }5757 58-func wordSearchSpecForElo(elo difficulty.Elo) wordSearchEloSpec {58+func wordSearchSpecForElo(base WordSearchMode, elo difficulty.Elo) wordSearchEloSpec {5959 score := difficulty.Score01(elo)6060 dirCount := 3 + int(math.Round(score*5))6161 if dirCount > len(selectionDirections) {@@ -64,14 +64,19 @@ func wordSearchSpecForElo(elo difficulty.Elo) wordSearchEloSpec {
6464 6565 minLen := 3 + int(math.Floor(score*3))6666 maxLen := 5 + int(math.Round(score*5))67+ maxAllowedLen := max(base.Width, base.Height)68+ minLen = min(minLen, maxAllowedLen)69+ maxLen = min(maxLen, maxAllowedLen)6770 if maxLen < minLen {6871 maxLen = minLen6972 }73+ wordCount := 6 + int(math.Round(score*9))74+ wordCount = min(wordCount, max(1, base.Width*base.Height/minLen))7075 7176 return wordSearchEloSpec{72- width: 10 + int(math.Round(score*10)),73- height: 10 + int(math.Round(score*10)),74- wordCount: 6 + int(math.Round(score*9)),77+ width: base.Width,78+ height: base.Height,79+ wordCount: wordCount,7580 minWordLen: minLen,7681 maxWordLen: maxLen,7782 allowedDirs: append([]Direction(nil), selectionDirections[:dirCount]...),@@ -18,16 +18,6 @@ func Normalize(s string) string {
1818 return puzzle.NormalizeName(s)1919 }2020 21-// Mode finds a mode within a game entry by name. If name is empty,22-// returns the first (default) mode.23-func Mode(entry registry.Entry, name string) (game.Spawner, string, error) {24- mode, err := ModeEntry(entry, name)25- if err != nil {26- return nil, "", err27- }28- return mode.Spawner, mode.Definition.Title, nil29-}30-3121 func ModeEntry(entry registry.Entry, name string) (registry.ModeEntry, error) {3222 if len(entry.Modes) == 0 {3323 return registry.ModeEntry{}, fmt.Errorf("game %q has no available modes", entry.Definition.Name)@@ -33,34 +33,34 @@ func TestNormalize(t *testing.T) {
3333 }3434 }3535 36-func TestMode(t *testing.T) {36+func TestModeEntry(t *testing.T) {3737 sudokuEntry, ok := registry.Resolve("Sudoku")3838 if !ok {3939 t.Fatal("missing sudoku entry")4040 }4141 4242 t.Run("empty name returns default (first) mode", func(t *testing.T) {43- spawner, title, err := Mode(sudokuEntry, "")43+ mode, err := ModeEntry(sudokuEntry, "")4444 if err != nil {4545 t.Fatal(err)4646 }47- if spawner == nil || title != "Beginner" {48- t.Fatalf("default mode = (%v, %q), want Beginner", spawner != nil, title)47+ if mode.Spawner == nil || mode.Definition.Title != "Beginner" {48+ t.Fatalf("default mode = (%v, %q), want Beginner", mode.Spawner != nil, mode.Definition.Title)4949 }5050 })5151 5252 t.Run("case insensitive match", func(t *testing.T) {53- _, title, err := Mode(sudokuEntry, "easy")53+ mode, err := ModeEntry(sudokuEntry, "easy")5454 if err != nil {5555 t.Fatal(err)5656 }57- if title != "Easy" {58- t.Fatalf("title = %q, want Easy", title)57+ if mode.Definition.Title != "Easy" {58+ t.Fatalf("title = %q, want Easy", mode.Definition.Title)5959 }6060 })6161 6262 t.Run("unknown mode", func(t *testing.T) {63- _, _, err := Mode(sudokuEntry, "impossible")63+ _, err := ModeEntry(sudokuEntry, "impossible")6464 if err == nil || !strings.Contains(err.Error(), "unknown mode") {6565 t.Fatalf("error = %v, want unknown mode", err)6666 }@@ -70,7 +70,7 @@ func TestMode(t *testing.T) {
7070 emptyEntry := registry.Entry{7171 Definition: puzzle.Definition{Name: "Empty"},7272 }73- _, _, err := Mode(emptyEntry, "any")73+ _, err := ModeEntry(emptyEntry, "any")7474 if err == nil || !strings.Contains(err.Error(), "no available modes") {7575 t.Fatalf("error = %v, want no available modes", err)7676 }