clone

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

clean up

Dami committed b28424a
commit
b28424a4feee29ba9f20b1dca3cf87e490c85732
parent
4455328a2b0b1e6728d1c4e92f8628165e972986
author
Dami <dami.etoile@gmail.com>
24 files changed, +371 -155
Mcmd/new.go25++++--
Mcmd/new_export.go30+++++--
Mcmd/new_export_test.go63+++++++++++++++
Acmd/new_spawn_test.go122++++++++++++++++++++++++++++++
Mgames/fillomino/elo.go10+-
Mgames/hashiwokakero/elo.go25+++---
Mgames/hashiwokakero/elo_test.go14+++
Mgames/hitori/elo.go6-
Mgames/lightsout/elo.go10+--
Mgames/lightsout/elo_test.go16++++
Mgames/netwalk/elo.go16+--
Mgames/nonogram/elo.go21+----
Mgames/nonogram/elo_test.go17++++
Mgames/nurikabe/elo.go21++---
Mgames/rippleeffect/elo.go20++---
Mgames/shikaku/elo.go9+-
Mgames/spellpuzzle/elo.go12++-
Mgames/sudoku/elo.go8+-
Mgames/sudokurgb/elo.go8+-
Mgames/takuzu/elo.go14+--
Mgames/takuzuplus/elo.go16+--
Mgames/wordsearch/elo.go15++-
Mresolve/resolve.go10--
Mresolve/resolve_test.go18++--
M cmd/new.go +17 -8
@@ -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 }
M cmd/new_export.go +21 -9
@@ -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 }
M cmd/new_export_test.go +63 -0
@@ -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
A cmd/new_spawn_test.go +122 -0
@@ -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+}
M games/fillomino/elo.go +6 -4
M games/hashiwokakero/elo.go +12 -13
M games/hashiwokakero/elo_test.go +14 -0
M games/hitori/elo.go +0 -6
M games/lightsout/elo.go +3 -7
M games/lightsout/elo_test.go +16 -0
M games/netwalk/elo.go +6 -10
M games/nonogram/elo.go +5 -16
M games/nonogram/elo_test.go +17 -0
M games/nurikabe/elo.go +8 -13
M games/rippleeffect/elo.go +8 -12
M games/shikaku/elo.go +5 -4
M games/spellpuzzle/elo.go +8 -4
M games/sudoku/elo.go +5 -3
M games/sudokurgb/elo.go +5 -3
M games/takuzu/elo.go +5 -9
M games/takuzuplus/elo.go +6 -10
M games/wordsearch/elo.go +10 -5
@@ -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]...),
M resolve/resolve.go +0 -10
M resolve/resolve_test.go +9 -9
@@ -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 }