clone

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

tech debt 4

Dami committed 4242efd
commit
4242efd6cc85182bcfbe9f8cf55579309e61f575
parent
504785ea074afcc37e053973bbdecb9eb5471480
author
Dami <dami.etoile@gmail.com>
22 files changed, +536 -337
Mfillomino/gamemode.go25+++++--
Mgame/game_test.go67------------------
Mgame/gamer.go70-------------------
Agamereg/gamereg.go99+++++++++++++++++++++++++++
Agamereg/gamereg_test.go72+++++++++++++++++++
Mhashiwokakero/gamemode.go25+++++--
Mhitori/gamemode.go23++++--
Mhitori/hitori_test.go2+-
Mlightsout/gamemode.go25+++++--
Mnonogram/gamemode.go23++++--
Mnurikabe/gamemode.go25+++++--
Mpuzzle/puzzle.go55+++++++++++++++
Mpuzzle/puzzle_test.go53++++++++++++++
Mregistry/registry.go111++++++------------------------
Mrippleeffect/gamemode.go25+++++--
Mshikaku/gamemode.go25+++++--
Mspellpuzzle/gamemode.go25+++++--
Msudoku/gamemode.go23++++--
Msudokurgb/gamemode.go25+++++--
Mtakuzu/gamemode.go25+++++--
Mtakuzuplus/gamemode.go25+++++--
Mwordsearch/gamemode.go25+++++--
M fillomino/gamemode.go +17 -8
M game/game_test.go +0 -67
@@ -202,73 +202,6 @@ func TestBaseMode(t *testing.T) {
202202 })203203 }204204 205-// --- Category (P3) ---206-207-func TestCategory(t *testing.T) {208- c := Category{Name: "Sudoku", Desc: "Classic"}209-210- if got := c.Title(); got != "Sudoku" {211- t.Errorf("Title() = %q, want %q", got, "Sudoku")212- }213- if got := c.Description(); got != "Classic" {214- t.Errorf("Description() = %q, want %q", got, "Classic")215- }216- if got := c.FilterValue(); got != "Sudoku" {217- t.Errorf("FilterValue() = %q, want %q", got, "Sudoku")218- }219-}220-221-// --- Definition (P1) ---222-223-func TestDefinitionCategory(t *testing.T) {224- def := Definition{225- Name: "Sudoku",226- Description: "Classic grid logic",227- Help: "Rules",228- }229-230- cat := def.Category()231- if cat.Name != def.Name {232- t.Fatalf("Category().Name = %q, want %q", cat.Name, def.Name)233- }234- if cat.Desc != def.Description {235- t.Fatalf("Category().Desc = %q, want %q", cat.Desc, def.Description)236- }237- if cat.Help != def.Help {238- t.Fatalf("Category().Help = %q, want %q", cat.Help, def.Help)239- }240-}241-242-func TestNewDefinitionSelectsDailyModesByIndex(t *testing.T) {243- modes := []Mode{244- NewBaseMode("Beginner", "Easy board"),245- NewBaseMode("Medium", "Balanced board"),246- NewBaseMode("Expert", "Hard board"),247- }248-249- def := NewDefinition(DefinitionSpec{250- Name: "Sudoku",251- Description: "Classic grid logic",252- Aliases: []string{"classic"},253- Modes: modes,254- DailyModeIndexes: []int{1, 2, 99, -1},255- Help: "Rules",256- })257-258- if got, want := len(def.DailyModes), 2; got != want {259- t.Fatalf("len(DailyModes) = %d, want %d", got, want)260- }261- if got, want := def.DailyModes[0].Title(), "Medium"; got != want {262- t.Fatalf("DailyModes[0] = %q, want %q", got, want)263- }264- if got, want := def.DailyModes[1].Title(), "Expert"; got != want {265- t.Fatalf("DailyModes[1] = %q, want %q", got, want)266- }267- if got, want := len(def.Aliases), 1; got != want {268- t.Fatalf("len(Aliases) = %d, want %d", got, want)269- }270-}271-272205 func TestNormalizeName(t *testing.T) {273206 tests := []struct {274207 input string
M game/gamer.go +0 -70
@@ -6,7 +6,6 @@ import (
66 "math/rand/v2"77 88 "charm.land/bubbles/v2/key"9- "charm.land/bubbles/v2/list"109 tea "charm.land/bubbletea/v2"1110 "github.com/FelineStateMachine/puzzletea/puzzle"1211 )
@@ -76,81 +75,12 @@ func (b BaseMode) Title() string { return b.title }
7675 func (b BaseMode) Description() string { return b.description }7776 func (b BaseMode) FilterValue() string { return b.title + " " + b.description }7877 79-// Category groups related game modes under a heading in the menu.80-type Category struct {81- Name string82- Desc string83- Modes []list.Item84- Help string // embedded help.md content rendered in "How to Play"85-}86-87-func (c Category) Title() string { return c.Name }88-func (c Category) Description() string { return c.Desc }89-func (c Category) FilterValue() string { return c.Name }90-91-// Definition is the package-level metadata for a puzzle game.92-type Definition struct {93- Name string94- Description string95- Aliases []string96- Modes []Mode97- DailyModes []Mode98- Help string99- Import func([]byte) (Gamer, error)100-}101-102-type DefinitionSpec struct {103- Name string104- Description string105- Aliases []string106- Modes []Mode107- DailyModeIndexes []int108- Help string109- Import func([]byte) (Gamer, error)110-}111-112-func NewDefinition(spec DefinitionSpec) Definition {113- return Definition{114- Name: spec.Name,115- Description: spec.Description,116- Aliases: append([]string(nil), spec.Aliases...),117- Modes: append([]Mode(nil), spec.Modes...),118- DailyModes: SelectModesByIndex(spec.Modes, spec.DailyModeIndexes...),119- Help: spec.Help,120- Import: spec.Import,121- }122-}123-124-func SelectModesByIndex(modes []Mode, indexes ...int) []Mode {125- selected := make([]Mode, 0, len(indexes))126- for _, idx := range indexes {127- if idx < 0 || idx >= len(modes) {128- continue129- }130- selected = append(selected, modes[idx])131- }132- return selected133-}134-13578 func AdaptImport[T Gamer](fn func([]byte) (T, error)) func([]byte) (Gamer, error) {13679 return func(data []byte) (Gamer, error) {13780 return fn(data)13881 }13982 }14083 141-func (d Definition) Category() Category {142- modes := make([]list.Item, len(d.Modes))143- for i, m := range d.Modes {144- modes[i] = m145- }146- return Category{147- Name: d.Name,148- Desc: d.Description,149- Modes: modes,150- Help: d.Help,151- }152-}153-15484 // NormalizeName lowercases and replaces hyphens/underscores with spaces for15585 // fuzzy matching of CLI arguments to game names and aliases.15686 func NormalizeName(s string) string {
A gamereg/gamereg.go +99 -0
@@ -0,0 +1,99 @@
1+package gamereg2+3+import (4+ "fmt"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/puzzle"8+)9+10+type ModeEntry struct {11+ Definition puzzle.ModeDef12+ Spawner game.Spawner13+ Seeded game.SeededSpawner14+}15+16+type Entry struct {17+ Definition puzzle.Definition18+ Help string19+ Import func([]byte) (game.Gamer, error)20+ Modes []ModeEntry21+}22+23+type EntrySpec struct {24+ Definition puzzle.Definition25+ Help string26+ Import func([]byte) (game.Gamer, error)27+ Modes []game.Mode28+}29+30+func BuildModeDefs(modes []game.Mode) []puzzle.ModeDef {31+ defs := make([]puzzle.ModeDef, 0, len(modes))32+ for _, mode := range modes {33+ def := puzzle.NewModeDef(puzzle.ModeSpec{34+ Title: mode.Title(),35+ Description: mode.Description(),36+ })37+ if _, ok := mode.(game.SeededSpawner); ok {38+ def.Seeded = true39+ }40+ defs = append(defs, def)41+ }42+ return defs43+}44+45+func NewEntry(spec EntrySpec) Entry {46+ if len(spec.Definition.Modes) != len(spec.Modes) {47+ panic(fmt.Sprintf(48+ "gamereg: definition %q has %d mode definitions but %d runtime modes",49+ spec.Definition.Name,50+ len(spec.Definition.Modes),51+ len(spec.Modes),52+ ))53+ }54+55+ entries := make([]ModeEntry, 0, len(spec.Modes))56+ for i, mode := range spec.Modes {57+ spawner, ok := mode.(game.Spawner)58+ if !ok {59+ panic(fmt.Sprintf(60+ "gamereg: definition %q mode %q does not implement game.Spawner",61+ spec.Definition.Name,62+ mode.Title(),63+ ))64+ }65+66+ modeDef := spec.Definition.Modes[i]67+ if modeDef.Title != mode.Title() || modeDef.Description != mode.Description() {68+ panic(fmt.Sprintf(69+ "gamereg: definition %q mode %d metadata does not match runtime mode %q",70+ spec.Definition.Name,71+ i,72+ mode.Title(),73+ ))74+ }75+76+ entry := ModeEntry{77+ Definition: modeDef,78+ Spawner: spawner,79+ }80+ if seeded, ok := mode.(game.SeededSpawner); ok {81+ entry.Seeded = seeded82+ }83+ if entry.Definition.Seeded != (entry.Seeded != nil) {84+ panic(fmt.Sprintf(85+ "gamereg: definition %q mode %q seeded flag does not match runtime mode",86+ spec.Definition.Name,87+ modeDef.Title,88+ ))89+ }90+ entries = append(entries, entry)91+ }92+93+ return Entry{94+ Definition: spec.Definition,95+ Help: spec.Help,96+ Import: spec.Import,97+ Modes: entries,98+ }99+}
A gamereg/gamereg_test.go +72 -0
@@ -0,0 +1,72 @@
1+package gamereg2+3+import (4+ "errors"5+ "math/rand/v2"6+ "testing"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/puzzle"10+)11+12+type stubMode struct {13+ game.BaseMode14+}15+16+func (m stubMode) Spawn() (game.Gamer, error) {17+ return nil, errors.New("not implemented")18+}19+20+type seededStubMode struct {21+ stubMode22+}23+24+func (m seededStubMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {25+ return nil, errors.New("not implemented")26+}27+28+func TestBuildModeDefsMarksSeededModes(t *testing.T) {29+ modes := []game.Mode{30+ stubMode{BaseMode: game.NewBaseMode("Beginner", "Easy board")},31+ seededStubMode{stubMode{BaseMode: game.NewBaseMode("Expert", "Hard board")}},32+ }33+34+ defs := BuildModeDefs(modes)35+ if len(defs) != 2 {36+ t.Fatalf("len(BuildModeDefs) = %d, want 2", len(defs))37+ }38+ if defs[0].Seeded {39+ t.Fatal("defs[0].Seeded = true, want false")40+ }41+ if !defs[1].Seeded {42+ t.Fatal("defs[1].Seeded = false, want true")43+ }44+}45+46+func TestNewEntryKeepsMetadataAndRuntimeModesAligned(t *testing.T) {47+ modes := []game.Mode{48+ seededStubMode{stubMode{BaseMode: game.NewBaseMode("Medium", "Balanced board")}},49+ }50+ definition := puzzle.NewDefinition(puzzle.DefinitionSpec{51+ Name: "Sudoku",52+ Modes: BuildModeDefs(modes),53+ DailyModeIDs: []puzzle.ModeID{"medium"},54+ })55+56+ entry := NewEntry(EntrySpec{57+ Definition: definition,58+ Help: "Rules",59+ Import: func([]byte) (game.Gamer, error) { return nil, nil },60+ Modes: modes,61+ })62+63+ if got, want := entry.Definition.Name, "Sudoku"; got != want {64+ t.Fatalf("Definition.Name = %q, want %q", got, want)65+ }66+ if got, want := len(entry.Modes), 1; got != want {67+ t.Fatalf("len(Modes) = %d, want %d", got, want)68+ }69+ if entry.Modes[0].Seeded == nil {70+ t.Fatal("Modes[0].Seeded = nil, want seeded spawner")71+ }72+}
M hashiwokakero/gamemode.go +17 -8
M hitori/gamemode.go +16 -7
M hitori/hitori_test.go +1 -1
M lightsout/gamemode.go +17 -8
M nonogram/gamemode.go +16 -7
M nurikabe/gamemode.go +17 -8
M puzzle/puzzle.go +55 -0
M puzzle/puzzle_test.go +53 -0
@@ -29,3 +29,56 @@ func TestCanonicalIDsUseNormalizedNames(t *testing.T) {
2929 t.Fatalf("CanonicalModeID() = %q, want %q", got, want)3030 }3131 }32+33+func TestNewModeDefAndDefinitionCloneInput(t *testing.T) {34+ mode := NewModeDef(ModeSpec{35+ Title: "Medium",36+ Description: "Balanced board",37+ Seeded: true,38+ })39+ if got, want := mode.ID, ModeID("medium"); got != want {40+ t.Fatalf("ModeDef.ID = %q, want %q", got, want)41+ }42+43+ spec := DefinitionSpec{44+ Name: "Sudoku",45+ Description: "Classic grid logic",46+ Aliases: []string{"classic"},47+ Modes: []ModeDef{mode},48+ DailyModeIDs: []ModeID{mode.ID},49+ }50+ def := NewDefinition(spec)51+52+ spec.Aliases[0] = "changed"53+ spec.Modes[0].Title = "Changed"54+ spec.DailyModeIDs[0] = "changed"55+56+ if got, want := def.ID, GameID("sudoku"); got != want {57+ t.Fatalf("Definition.ID = %q, want %q", got, want)58+ }59+ if got, want := def.Aliases[0], "classic"; got != want {60+ t.Fatalf("Aliases[0] = %q, want %q", got, want)61+ }62+ if got, want := def.Modes[0].Title, "Medium"; got != want {63+ t.Fatalf("Modes[0].Title = %q, want %q", got, want)64+ }65+ if got, want := def.DailyModeIDs[0], ModeID("medium"); got != want {66+ t.Fatalf("DailyModeIDs[0] = %q, want %q", got, want)67+ }68+}69+70+func TestSelectModeIDsByIndex(t *testing.T) {71+ modes := []ModeDef{72+ NewModeDef(ModeSpec{Title: "Beginner"}),73+ NewModeDef(ModeSpec{Title: "Medium"}),74+ NewModeDef(ModeSpec{Title: "Expert"}),75+ }76+77+ got := SelectModeIDsByIndex(modes, 1, 2, 99, -1)78+ if len(got) != 2 {79+ t.Fatalf("len(SelectModeIDsByIndex) = %d, want 2", len(got))80+ }81+ if got[0] != ModeID("medium") || got[1] != ModeID("expert") {82+ t.Fatalf("SelectModeIDsByIndex = %v, want [medium expert]", got)83+ }84+}
M registry/registry.go +21 -90
@@ -6,6 +6,7 @@ import (
66 "github.com/FelineStateMachine/puzzletea/catalog"77 "github.com/FelineStateMachine/puzzletea/fillomino"88 "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/gamereg"910 "github.com/FelineStateMachine/puzzletea/hashiwokakero"1011 "github.com/FelineStateMachine/puzzletea/hitori"1112 "github.com/FelineStateMachine/puzzletea/lightsout"
@@ -22,18 +23,10 @@ import (
2223 "github.com/FelineStateMachine/puzzletea/wordsearch"2324 )2425 25-type ModeEntry struct {26- Definition puzzle.ModeDef27- Spawner game.Spawner28- Seeded game.SeededSpawner29-}30-31-type Entry struct {32- Definition puzzle.Definition33- Help string34- Import func([]byte) (game.Gamer, error)35- Modes []ModeEntry36-}26+type (27+ ModeEntry = gamereg.ModeEntry28+ Entry = gamereg.Entry29+)3730 3831 type DailyEntry struct {3932 Spawner game.SeededSpawner
@@ -43,22 +36,22 @@ type DailyEntry struct {
4336 Mode string4437 }4538 46-var all = buildEntries(47- fillomino.Definition,48- hashiwokakero.Definition,49- hitori.Definition,50- lightsout.Definition,51- nonogram.Definition,52- nurikabe.Definition,53- rippleeffect.Definition,54- shikaku.Definition,55- spellpuzzle.Definition,56- sudoku.Definition,57- sudokurgb.Definition,58- takuzu.Definition,59- takuzuplus.Definition,60- wordsearch.Definition,61-)39+var all = []Entry{40+ fillomino.Entry,41+ hashiwokakero.Entry,42+ hitori.Entry,43+ lightsout.Entry,44+ nonogram.Entry,45+ nurikabe.Entry,46+ rippleeffect.Entry,47+ shikaku.Entry,48+ spellpuzzle.Entry,49+ sudoku.Entry,50+ sudokurgb.Entry,51+ takuzu.Entry,52+ takuzuplus.Entry,53+ wordsearch.Entry,54+}6255 6356 var (6457 definitions = buildDefinitions(all)
@@ -66,68 +59,6 @@ var (
6659 entriesByID = buildEntriesByID(all)6760 )6861 69-func adaptLegacy(def game.Definition) Entry {70- gameID := puzzle.CanonicalGameID(def.Name)71- modes := make([]ModeEntry, 0, len(def.Modes))72- for _, mode := range def.Modes {73- spawner, ok := mode.(game.Spawner)74- if !ok {75- continue76- }77- modeID := puzzle.CanonicalModeID(mode.Title())78- modeDef := puzzle.ModeDef{79- ID: modeID,80- Title: mode.Title(),81- Description: mode.Description(),82- }83- var seeded game.SeededSpawner84- if s, ok := mode.(game.SeededSpawner); ok {85- modeDef.Seeded = true86- seeded = s87- }88- modes = append(modes, ModeEntry{89- Definition: modeDef,90- Spawner: spawner,91- Seeded: seeded,92- })93- }94-95- dailyIDs := make([]puzzle.ModeID, 0, len(def.DailyModes))96- for _, mode := range def.DailyModes {97- dailyIDs = append(dailyIDs, puzzle.CanonicalModeID(mode.Title()))98- }99-100- return Entry{101- Definition: puzzle.Definition{102- ID: gameID,103- Name: def.Name,104- Description: def.Description,105- Aliases: append([]string(nil), def.Aliases...),106- Modes: extractModeDefs(modes),107- DailyModeIDs: dailyIDs,108- },109- Help: def.Help,110- Import: def.Import,111- Modes: modes,112- }113-}114-115-func buildEntries(defs ...game.Definition) []Entry {116- entries := make([]Entry, 0, len(defs))117- for _, def := range defs {118- entries = append(entries, adaptLegacy(def))119- }120- return entries121-}122-123-func extractModeDefs(modes []ModeEntry) []puzzle.ModeDef {124- defs := make([]puzzle.ModeDef, 0, len(modes))125- for _, mode := range modes {126- defs = append(defs, mode.Definition)127- }128- return defs129-}130-13162 func buildDefinitions(entries []Entry) []puzzle.Definition {13263 defs := make([]puzzle.Definition, 0, len(entries))13364 for _, entry := range entries {
M rippleeffect/gamemode.go +17 -8
M shikaku/gamemode.go +17 -8
M spellpuzzle/gamemode.go +17 -8
M sudoku/gamemode.go +16 -7
M sudokurgb/gamemode.go +17 -8
M takuzu/gamemode.go +17 -8
M takuzuplus/gamemode.go +17 -8
M wordsearch/gamemode.go +17 -8