clone

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

Introduce variant Elo schema v2

Dami committed 23f4ddd
commit
23f4ddd1fcfe76627de3befa727be0527c05f9ce
parent
b28424a4feee29ba9f20b1dca3cf87e490c85732
author
Dami <dami.etoile@gmail.com>
38 files changed, +870 -334
Mapp/create_state.go92++------------
Mapp/create_state_test.go40++++--
Mapp/export_test.go2+-
Mapp/handle_create.go2+-
Mapp/handle_create_test.go21+--
Mapp/mode_display.go24++--
Mapp/mode_display_test.go22++--
Mapp/screen_core.go10+-
Mapp/screen_create.go2+-
Mapp/screen_create_test.go35++++--
Mapp/screen_navigation.go4+-
Mapp/seed_input.go24+---
Mapp/view.go8+-
Mcatalog/catalog.go25++++
Mcmd/new.go29++++-
Mcmd/new_export.go69+++++------
Mcmd/new_export_test.go6+-
Mcmd/new_spawn_test.go43+++++++
Mcmd/test.go2+-
Mdaily/daily_test.go8+-
Mexport/pack/packexport_test.go21+++-
Mexport/pack/render.go4+-
Mexport/pack/spec.go109++++++++++-------
Mexport/pdf/jsonl.go16++-
Mgameentry/gameentry.go188+++++++++++++++++++++++++++---
Mgames/nurikabe/generator.go9+-
Mpuzzle/puzzle.go81+++++++++++++
Mregistry/registry.go23++--
Mregistry/registry_test.go25++++
Mresolve/resolve.go91++++++++++++++-
Mresolve/resolve_test.go37+++++-
Msession/session.go2+
Mstats/stats.go17++-
Mstore/game_record.go2+
Mstore/migrate.go63++++++++++-
Mstore/store.go28+++--
Mstore/store_test.go12++
Mweekly/weekly_test.go8+-
M app/create_state.go +15 -77
@@ -2,7 +2,6 @@ package app
22 33 import (44 "fmt"5- "regexp"65 "sort"76 "strconv"87 "strings"
@@ -18,8 +17,6 @@ import (
1817 1918 const defaultCreateElo = 12002019 21-var boardSizePattern = regexp.MustCompile(`(?i)(\d+)\s*[x×]\s*(\d+)`)22-2320 type createState struct {2421 tree []createTreeNode2522 checked map[string]bool
@@ -47,11 +44,10 @@ type createTreeNode struct {
4744 }4845 4946 type createLeaf struct {50- id string51- title string52- gameType string53- modeTitle string54- modes []registry.ModeEntry47+ id string48+ title string49+ gameType string50+ variant registry.VariantEntry5551 }5652 5753 type createVisibleNode struct {
@@ -154,32 +150,21 @@ func createGrouping(gameType string) (topTitle, branchTitle string) {
154150 }155151 156152 func createLeavesForEntry(entry registry.Entry, branchTitle string) []createTreeNode {157- groups := make(map[string][]registry.ModeEntry)158- order := make([]string, 0, len(entry.Modes))159- for _, mode := range entry.Modes {160- if mode.Elo == nil {153+ nodes := make([]createTreeNode, 0, len(entry.Variants))154+ for _, variant := range entry.Variants {155+ if variant.Elo == nil {161156 continue162157 }163- title := createLeafTitle(entry.Definition.Name, entry.Definition.Description, mode.Definition.Title, mode.Definition.Description)164- if _, ok := groups[title]; !ok {165- order = append(order, title)166- }167- groups[title] = append(groups[title], mode)168- }169-170- nodes := make([]createTreeNode, 0, len(order))171- for _, title := range order {158+ title := variant.Definition.Title172159 id := createLeafID(entry.Definition.ID, branchTitle, title)173- modes := groups[title]174160 nodes = append(nodes, createTreeNode{175161 id: id,176162 title: title,177163 leaf: &createLeaf{178- id: id,179- title: title,180- gameType: entry.Definition.Name,181- modeTitle: modes[0].Definition.Title,182- modes: modes,164+ id: id,165+ title: title,166+ gameType: entry.Definition.Name,167+ variant: variant,183168 },184169 })185170 }
@@ -195,30 +180,6 @@ func createLeafID(gameID puzzle.GameID, branchTitle, title string) string {
195180 return strings.Join(parts, "/")196181 }197182 198-func createLeafTitle(gameType, gameDescription, modeTitle, modeDescription string) string {199- if size := firstBoardSize(modeTitle); size != "" {200- return size201- }202- if size := firstBoardSize(modeDescription); size != "" {203- return size204- }205- if gameType == "Sudoku RGB" {206- return "9x9"207- }208- if size := firstBoardSize(gameDescription); size != "" {209- return size210- }211- return modeTitle212-}213-214-func firstBoardSize(s string) string {215- match := boardSizePattern.FindStringSubmatch(s)216- if len(match) != 3 {217- return ""218- }219- return match[1] + "x" + match[2]220-}221-222183 func (s createState) selectedLeaves() []createLeaf {223184 leaves := make([]createLeaf, 0, len(s.checked))224185 var walk func([]createTreeNode)
@@ -348,32 +309,9 @@ func (s *createState) expandAncestors(leafID string) {
348309 walk(s.tree, nil)349310 }350311 351-func (s createState) resolveLeafMode(leaf createLeaf, elo difficulty.Elo) (game.EloSpawner, string, error) {352- if len(leaf.modes) == 0 {353- return nil, "", fmt.Errorf("no Elo-capable modes for %s", leaf.title)354- }355- best := leaf.modes[0]356- bestDistance := createPresetDistance(best, elo)357- for _, mode := range leaf.modes[1:] {358- distance := createPresetDistance(mode, elo)359- if distance < bestDistance {360- best = mode361- bestDistance = distance362- }363- }364- if best.Elo == nil {312+func (s createState) resolveLeafVariant(leaf createLeaf, elo difficulty.Elo) (game.EloSpawner, string, error) {313+ if leaf.variant.Elo == nil {365314 return nil, "", fmt.Errorf("%s %s does not support Elo generation", leaf.gameType, leaf.title)366315 }367- return best.Elo, best.Definition.Title, nil368-}369-370-func createPresetDistance(mode registry.ModeEntry, elo difficulty.Elo) int {371- if mode.Definition.PresetElo == nil {372- return 0373- }374- delta := int(*mode.Definition.PresetElo - elo)375- if delta < 0 {376- return -delta377- }378- return delta316+ return leaf.variant.Elo, leaf.variant.Definition.Title, nil379317 }
M app/create_state_test.go +27 -13
@@ -8,7 +8,7 @@ import (
88 )99 1010 func TestCreateStateStartsCollapsedAndRestoresCheckedLeaves(t *testing.T) {11- const leafID = "nonogram/5x5"11+ const leafID = "nonogram/nonogram"1212 state := newCreateState(config.CreateConfig{1313 SelectedLeafIDs: []string{leafID},1414 Elo: 1500,
@@ -47,11 +47,11 @@ func TestCreateTreeGroupsTakuzuAndSudokuVariants(t *testing.T) {
4747 }4848 }4949 50-func TestCreateTreeCollapsesNonogramByBoardSize(t *testing.T) {50+func TestCreateTreeListsNonogramRuleVariant(t *testing.T) {5151 state := newCreateState(config.CreateConfig{}, 80)5252 nonogram := requireCreateNode(t, state.tree, "Nonogram")5353 54- want := []string{"5x5", "10x10", "15x15", "20x20"}54+ want := []string{"Nonogram"}5555 got := childTitles(nonogram)5656 if len(got) != len(want) {5757 t.Fatalf("Nonogram leaves = %#v, want %#v", got, want)
@@ -63,20 +63,20 @@ func TestCreateTreeCollapsesNonogramByBoardSize(t *testing.T) {
6363 }6464 }6565 66-func TestCreateResolveLeafModeUsesNearestPresetElo(t *testing.T) {66+func TestCreateResolveLeafVariantUsesSelectedVariant(t *testing.T) {6767 state := newCreateState(config.CreateConfig{}, 80)6868 nonogram := requireCreateNode(t, state.tree, "Nonogram")69- leaf := requireCreateNode(t, nonogram.children, "5x5").leaf69+ leaf := requireCreateNode(t, nonogram.children, "Nonogram").leaf7070 if leaf == nil {71- t.Fatal("5x5 node is not a leaf")71+ t.Fatal("Nonogram node is not a leaf")7272 }7373 74- _, modeTitle, err := state.resolveLeafMode(*leaf, difficulty.Elo(700))74+ _, modeTitle, err := state.resolveLeafVariant(*leaf, difficulty.Elo(700))7575 if err != nil {7676 t.Fatal(err)7777 }78- if modeTitle != "Teaser" {79- t.Fatalf("modeTitle = %q, want Teaser", modeTitle)78+ if modeTitle != "Nonogram" {79+ t.Fatalf("modeTitle = %q, want Nonogram", modeTitle)8080 }8181 }8282
@@ -93,12 +93,12 @@ func TestCreateStateDescendantLeafCountsIncludeCollapsedDescendants(t *testing.T
9393 9494 func TestCreateStateToggleDescendantLeavesSelectsAllWhenAnyUnchecked(t *testing.T) {9595 state := newCreateState(config.CreateConfig{}, 80)96- nonogram := requireCreateNode(t, state.tree, "Nonogram")97- state.checked[nonogram.children[0].leaf.id] = true96+ sudoku := requireCreateNode(t, state.tree, "Sudoku")97+ state.checked[firstDescendantLeafID(t, sudoku.children[0])] = true9898 99- state.toggleDescendantLeaves(nonogram)99+ state.toggleDescendantLeaves(sudoku)100100 101- selected, total := state.descendantLeafCounts(nonogram)101+ selected, total := state.descendantLeafCounts(sudoku)102102 if selected != total || total == 0 {103103 t.Fatalf("descendant counts after select-all = %d/%d, want all selected", selected, total)104104 }
@@ -157,3 +157,17 @@ func childTitles(node createTreeNode) []string {
157157 }158158 return titles159159 }160+161+func firstDescendantLeafID(t *testing.T, node createTreeNode) string {162+ t.Helper()163+ if node.leaf != nil {164+ return node.leaf.id165+ }166+ for _, child := range node.children {167+ if id := firstDescendantLeafID(t, child); id != "" {168+ return id169+ }170+ }171+ t.Fatalf("node %q has no descendant leaf", node.title)172+ return ""173+}
M app/export_test.go +1 -1
M app/handle_create.go +1 -1
M app/handle_create_test.go +8 -13
M app/mode_display.go +12 -12
@@ -9,22 +9,22 @@ import (
99 )1010 1111 type modeDisplayItem struct {12- mode registry.ModeEntry12+ mode registry.VariantEntry1313 title string1414 description string1515 filterValue string1616 }1717 18-func (i modeDisplayItem) Title() string { return i.title }19-func (i modeDisplayItem) Description() string { return i.description }20-func (i modeDisplayItem) FilterValue() string { return i.filterValue }21-func (i modeDisplayItem) Original() registry.ModeEntry { return i.mode }18+func (i modeDisplayItem) Title() string { return i.title }19+func (i modeDisplayItem) Description() string { return i.description }20+func (i modeDisplayItem) FilterValue() string { return i.filterValue }21+func (i modeDisplayItem) Original() registry.VariantEntry { return i.mode }2222 2323 func buildModeDisplayItems(entry registry.Entry) []list.Item {2424 titles := modeDisplayTitles(entry)25- items := make([]list.Item, 0, len(entry.Modes))25+ items := make([]list.Item, 0, len(entry.Variants))2626 27- for idx, mode := range entry.Modes {27+ for idx, mode := range entry.Variants {2828 displayTitle := titles[idx]2929 filterValue := displayTitle + " " + mode.Definition.Description3030 if displayTitle != mode.Definition.Title {
@@ -43,15 +43,15 @@ func buildModeDisplayItems(entry registry.Entry) []list.Item {
4343 }4444 4545 func modeDisplayTitles(entry registry.Entry) []string {46- counts := make(map[string]int, len(entry.Modes))47- titles := make([]string, 0, len(entry.Modes))46+ counts := make(map[string]int, len(entry.Variants))47+ titles := make([]string, 0, len(entry.Variants))4848 49- for _, mode := range entry.Modes {49+ for _, mode := range entry.Variants {5050 base, _ := trimGridSizeSuffix(mode.Definition.Title)5151 counts[puzzle.NormalizeName(base)]++5252 }5353 54- for _, mode := range entry.Modes {54+ for _, mode := range entry.Variants {5555 title := mode.Definition.Title5656 base, hadSize := trimGridSizeSuffix(title)5757 if hadSize && counts[puzzle.NormalizeName(base)] == 1 {
@@ -64,7 +64,7 @@ func modeDisplayTitles(entry registry.Entry) []string {
6464 }6565 6666 func unwrapModeDisplayItem(item list.Item) any {67- displayItem, ok := item.(interface{ Original() registry.ModeEntry })67+ displayItem, ok := item.(interface{ Original() registry.VariantEntry })6868 if !ok {6969 return item7070 }
M app/mode_display_test.go +11 -11
@@ -21,9 +21,9 @@ func TestModeDisplayTitlesStripUniqueGridSizes(t *testing.T) {
2121 2222 entry := registry.Entry{2323 Definition: puzzle.Definition{Name: "Example"},24- Modes: []registry.ModeEntry{25- {Definition: puzzle.ModeDef{Title: "Mini 5x5", Description: "Mini 5x5 description"}},26- {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},24+ Variants: []registry.VariantEntry{25+ {Definition: puzzle.VariantDef{Title: "Mini 5x5", Description: "Mini 5x5 description"}},26+ {Definition: puzzle.VariantDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},2727 },2828 }2929
@@ -45,10 +45,10 @@ func TestModeDisplayTitlesKeepDuplicateBaseNames(t *testing.T) {
4545 4646 entry := registry.Entry{4747 Definition: puzzle.Definition{Name: "Example"},48- Modes: []registry.ModeEntry{49- {Definition: puzzle.ModeDef{Title: "Easy 5x5", Description: "Easy 5x5 description"}},50- {Definition: puzzle.ModeDef{Title: "Easy 10x10", Description: "Easy 10x10 description"}},51- {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},48+ Variants: []registry.VariantEntry{49+ {Definition: puzzle.VariantDef{Title: "Easy 5x5", Description: "Easy 5x5 description"}},50+ {Definition: puzzle.VariantDef{Title: "Easy 10x10", Description: "Easy 10x10 description"}},51+ {Definition: puzzle.VariantDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},5252 },5353 }5454
@@ -71,8 +71,8 @@ func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {
7171 original := newTestMode("Hard 10x10")7272 entry := registry.Entry{7373 Definition: puzzle.Definition{Name: "Example"},74- Modes: []registry.ModeEntry{{75- Definition: puzzle.ModeDef{Title: original.Title(), Description: original.Description()},74+ Variants: []registry.VariantEntry{{75+ Definition: puzzle.VariantDef{Title: original.Title(), Description: original.Description()},7676 }},7777 }7878
@@ -89,9 +89,9 @@ func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {
8989 t.Fatalf("display item Title() = %q, want %q", got, "Hard")9090 }9191 92- mode, ok := unwrapModeDisplayItem(items[0]).(registry.ModeEntry)92+ mode, ok := unwrapModeDisplayItem(items[0]).(registry.VariantEntry)9393 if !ok {94- t.Fatal("unwrapModeDisplayItem() did not return a registry.ModeEntry")94+ t.Fatal("unwrapModeDisplayItem() did not return a registry.VariantEntry")9595 }9696 if got := mode.Definition.Title; got != "Hard 10x10" {9797 t.Fatalf("unwrapped mode Title() = %q, want %q", got, "Hard 10x10")
M app/screen_core.go +5 -5
@@ -109,7 +109,7 @@ type gameSelectEnterAction struct {
109109 110110 func (a gameSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {111111 m.nav.selectedCategory = a.entry112- m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(a.entry), a.entry.Definition.Name+" - Select Mode")112+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(a.entry), a.entry.Definition.Name+" - Select Variant")113113 m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))114114 m.state = modeSelectView115115 m = m.initScreen(modeSelectView)
@@ -118,7 +118,7 @@ func (a gameSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {
118118 119119 type modeSelectEnterAction struct {120120 entry registry.Entry121- mode registry.ModeEntry121+ mode registry.VariantEntry122122 }123123 124124 func (a modeSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {
@@ -134,11 +134,11 @@ func (a modeSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {
134134 returnState: modeSelectView,135135 exitState: mainMenuView,136136 }137- if a.mode.Definition.PresetElo != nil && a.mode.Elo != nil {138- cmd := newSessionController(&m).startEloSpawn(a.mode.Elo, name, *a.mode.Definition.PresetElo, request)137+ if a.mode.Elo != nil {138+ cmd := newSessionController(&m).startEloSpawn(a.mode.Elo, name, a.mode.Definition.DefaultElo, request)139139 return m, cmd140140 }141- cmd := newSessionController(&m).startSpawn(a.mode.Spawner, request)141+ cmd := newSessionController(&m).startSpawn(a.mode.Seeded, request)142142 return m, cmd143143 }144144
M app/screen_create.go +1 -1
M app/screen_create_test.go +23 -12
@@ -209,34 +209,45 @@ func TestCreateScreenMouseClickTreeParentExpands(t *testing.T) {
209209 }210210 211211 func TestCreateScreenRenderedParentRowsIncludeRollupCounts(t *testing.T) {212+ state := newCreateState(config.CreateConfig{}, 80)213+ sudoku := requireCreateNode(t, state.tree, "Sudoku")214+ state.checked[firstDescendantLeafID(t, sudoku.children[0])] = true215+216+ line := renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)217+ if !strings.Contains(line, "Sudoku") || !strings.Contains(line, "1/2") {218+ t.Fatalf("rendered line = %q, want Sudoku with 1/2 rollup", line)219+ }220+}221+222+func TestCreateScreenRenderedParentRowsHideSingletonRollupCounts(t *testing.T) {212223 state := newCreateState(config.CreateConfig{}, 80)213224 nonogram := requireCreateNode(t, state.tree, "Nonogram")214- state.checked[nonogram.children[0].leaf.id] = true225+ state.checked[firstDescendantLeafID(t, nonogram)] = true215226 216227 line := renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)217- if !strings.Contains(line, "Nonogram") || !strings.Contains(line, "1/4") {218- t.Fatalf("rendered line = %q, want Nonogram with 1/4 rollup", line)228+ if strings.Contains(line, "1/1") {229+ t.Fatalf("rendered line = %q, want singleton parent without rollup count", line)219230 }220231 }221232 222233 func TestCreateScreenRenderedParentRowsSignalSelectionState(t *testing.T) {223234 state := newCreateState(config.CreateConfig{}, 80)224- nonogram := requireCreateNode(t, state.tree, "Nonogram")235+ sudoku := requireCreateNode(t, state.tree, "Sudoku")225236 226- line := renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)227- if !strings.Contains(line, "[ ] Nonogram") {237+ line := renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)238+ if !strings.Contains(line, "[ ] Sudoku") {228239 t.Fatalf("unchecked parent line = %q, want unchecked marker", line)229240 }230241 231- state.checked[nonogram.children[0].leaf.id] = true232- line = renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)233- if !strings.Contains(line, "[-] Nonogram") {242+ state.checked[firstDescendantLeafID(t, sudoku.children[0])] = true243+ line = renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)244+ if !strings.Contains(line, "[-] Sudoku") {234245 t.Fatalf("partial parent line = %q, want partial marker", line)235246 }236247 237- state.toggleDescendantLeaves(nonogram)238- line = renderCreateTreeLine(state, createVisibleNode{node: nonogram}, false)239- if !strings.Contains(line, "[x] Nonogram") {248+ state.toggleDescendantLeaves(sudoku)249+ line = renderCreateTreeLine(state, createVisibleNode{node: sudoku}, false)250+ if !strings.Contains(line, "[x] Sudoku") {240251 t.Fatalf("selected parent line = %q, want checked marker", line)241252 }242253 }
M app/screen_navigation.go +2 -2
M app/seed_input.go +6 -18
M app/view.go +4 -4
M catalog/catalog.go +25 -0
@@ -5,6 +5,7 @@ package catalog
55 import (66 "fmt"77 8+ "github.com/FelineStateMachine/puzzletea/difficulty"89 "github.com/FelineStateMachine/puzzletea/puzzle"910 )1011
@@ -95,6 +96,30 @@ func Validate(definitions []puzzle.Definition) error {
9596 }9697 modeTitles[mode.ID] = struct{}{}9798 }99+ variantIDs := make(map[puzzle.VariantID]struct{}, len(def.Variants))100+ for _, variant := range def.Variants {101+ if variant.ID == "" {102+ return fmt.Errorf("game %q has variant %q with empty id", def.Name, variant.Title)103+ }104+ if err := difficulty.ValidateElo(variant.DefaultElo); err != nil {105+ return fmt.Errorf("game %q variant %q default Elo is invalid: %w", def.Name, variant.Title, err)106+ }107+ if _, exists := variantIDs[variant.ID]; exists {108+ return fmt.Errorf("game %q has duplicate variant %q", def.Name, variant.ID)109+ }110+ variantIDs[variant.ID] = struct{}{}111+ }112+ for _, alias := range def.LegacyModes {113+ if alias.ID == "" {114+ return fmt.Errorf("game %q has legacy mode %q with empty id", def.Name, alias.Title)115+ }116+ if _, ok := variantIDs[alias.TargetVariantID]; !ok {117+ return fmt.Errorf("game %q legacy mode %q targets missing variant %q", def.Name, alias.Title, alias.TargetVariantID)118+ }119+ if err := difficulty.ValidateElo(alias.PresetElo); err != nil {120+ return fmt.Errorf("game %q legacy mode %q preset Elo is invalid: %w", def.Name, alias.Title, err)121+ }122+ }98123 99124 for _, dailyID := range def.DailyModeIDs {100125 if _, ok := modeTitles[dailyID]; !ok {
M cmd/new.go +26 -3
@@ -73,7 +73,7 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
7373 return err7474 }7575 76- mode, err := resolve.ModeEntry(entry, modeArg)76+ selection, err := resolve.VariantEntry(entry, modeArg)7777 if err != nil {7878 return err7979 }
@@ -82,6 +82,9 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
8282 if err != nil {8383 return err8484 }85+ if targetElo == nil {86+ targetElo = selection.ExplicitElo87+ }8588 8689 s, err := openStoreFn(cfg.DBPath)8790 if err != nil {
@@ -91,14 +94,14 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
9194 9295 name := sessionflow.GenerateUniqueName(s)9396 94- g, report, err := spawnFromMode(mode, seed, targetElo, name)97+ g, report, err := spawnFromVariant(selection.Variant, seed, targetElo, name)9598 if err != nil {9699 return fmt.Errorf("failed to spawn game: %w", err)97100 }98101 g = g.SetTitle(name)99102 100103 meta := difficultyMetadataFromReport(report)101- rec, err := sessionflow.CreateRecordWithDifficulty(s, g, name, entry.Definition.Name, mode.Definition.Title, store.NormalRunMetadata(), meta)104+ rec, err := sessionflow.CreateRecordWithDifficulty(s, g, name, entry.Definition.Name, selection.DisplayTitle, store.NormalRunMetadata(), meta)102105 if err != nil {103106 return err104107 }
@@ -106,6 +109,26 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
106109 return runGameProgramFn(s, cfg, activeConfigPath(), g, rec.ID, false)107110 }108111 112+func spawnFromVariant(variant registry.VariantEntry, seed string, targetElo *difficulty.Elo, fallbackEloSeed string) (game.Gamer, difficulty.Report, error) {113+ effectiveElo := targetElo114+ if effectiveElo == nil {115+ elo := variant.Definition.DefaultElo116+ effectiveElo = &elo117+ }118+ if variant.Elo == nil {119+ return nil, difficulty.Report{}, fmt.Errorf("variant does not support Elo difficulty")120+ }121+ eloSeed := seed122+ if eloSeed == "" {123+ eloSeed = fallbackEloSeed124+ }125+ g, report, err := variant.Elo.SpawnElo(eloSeed, *effectiveElo)126+ if err != nil {127+ return nil, difficulty.Report{}, err128+ }129+ return g, report, nil130+}131+109132 func spawnFromMode(mode registry.ModeEntry, seed string, targetElo *difficulty.Elo, fallbackEloSeed string) (game.Gamer, difficulty.Report, error) {110133 effectiveElo := targetElo111134 if effectiveElo == nil {
M cmd/new_export.go +29 -40
@@ -23,10 +23,9 @@ import (
2323 var exportNow = time.Now2424 2525 type exportModeEntry struct {26- spawner game.Spawner27- elo game.EloSpawner28- presetElo *difficulty.Elo29- mode string26+ elo game.EloSpawner27+ defaultElo difficulty.Elo28+ mode string3029 }3130 3231 func runNewExport(cmd *cobra.Command, args []string) error {
@@ -94,32 +93,34 @@ func validateNewExportFlags(_ *cobra.Command, args []string) error {
9493 9594 func collectExportModes(entry registry.Entry, modeArg string) ([]exportModeEntry, string, error) {9695 if modeArg != "" {97- mode, err := resolve.ModeEntry(entry, modeArg)96+ selection, err := resolve.VariantEntry(entry, modeArg)9897 if err != nil {9998 return nil, "", err10099 }100+ defaultElo := selection.Variant.Definition.DefaultElo101+ if selection.ExplicitElo != nil {102+ defaultElo = *selection.ExplicitElo103+ }101104 return []exportModeEntry{{102- spawner: mode.Spawner,103- elo: mode.Elo,104- presetElo: mode.Definition.PresetElo,105- mode: mode.Definition.Title,106- }}, mode.Definition.Title, nil105+ elo: selection.Variant.Elo,106+ defaultElo: defaultElo,107+ mode: selection.Variant.Definition.Title,108+ }}, selection.Variant.Definition.Title, nil107109 }108110 109- entries := make([]exportModeEntry, 0, len(entry.Modes))110- for _, mode := range entry.Modes {111+ entries := make([]exportModeEntry, 0, len(entry.Variants))112+ for _, variant := range entry.Variants {111113 entries = append(entries, exportModeEntry{112- spawner: mode.Spawner,113- elo: mode.Elo,114- presetElo: mode.Definition.PresetElo,115- mode: mode.Definition.Title,114+ elo: variant.Elo,115+ defaultElo: variant.Definition.DefaultElo,116+ mode: variant.Definition.Title,116117 })117118 }118119 if len(entries) == 0 {119- return nil, "", fmt.Errorf("game %q has no exportable modes", entry.Definition.Name)120+ return nil, "", fmt.Errorf("game %q has no exportable variants", entry.Definition.Name)120121 }121122 122- return entries, "mixed modes", nil123+ return entries, "mixed variants", nil123124 }124125 125126 func buildExportRecords(
@@ -169,7 +170,7 @@ func buildExportRecords(
169170 }170171 171172 records = append(records, pdfexport.JSONLRecord{172- Schema: pdfexport.ExportSchemaV1,173+ Schema: pdfexport.ExportSchemaV2,173174 Pack: pdfexport.JSONLPackMeta{174175 Generated: generatedAt.Format(time.RFC3339),175176 Version: Version,
@@ -205,31 +206,19 @@ func scopedExportEloSeed(seed string, generatedAt time.Time, gameType, mode stri
205206 func spawnExportPuzzle(entry exportModeEntry, rng *rand.Rand, seed string, targetElo *difficulty.Elo) (game.Gamer, difficulty.Report, error) {206207 effectiveElo := targetElo207208 if effectiveElo == nil {208- effectiveElo = entry.presetElo209+ effectiveElo = &entry.defaultElo209210 }210-211- if effectiveElo != nil {212- if entry.elo == nil {213- return nil, difficulty.Report{}, fmt.Errorf("mode does not support Elo difficulty")214- }215- g, report, err := entry.elo.SpawnElo(seed, *effectiveElo)216- if err != nil {217- return nil, difficulty.Report{}, err218- }219- return g, report, nil211+ if entry.elo == nil {212+ return nil, difficulty.Report{}, fmt.Errorf("variant does not support Elo difficulty")220213 }221-222- if rng == nil {223- g, err := entry.spawner.Spawn()224- return g, difficulty.Report{}, err214+ if rng != nil && strings.TrimSpace(seed) == "" {215+ seed = fmt.Sprintf("export-seeded:%016x:%016x", rng.Uint64(), rng.Uint64())225216 }226-227- seeded, ok := entry.spawner.(game.SeededSpawner)228- if !ok {229- return nil, difficulty.Report{}, fmt.Errorf("mode does not support deterministic spawning")217+ g, report, err := entry.elo.SpawnElo(seed, *effectiveElo)218+ if err != nil {219+ return nil, difficulty.Report{}, err230220 }231- g, err := seeded.SpawnSeeded(rng)232- return g, difficulty.Report{}, err221+ return g, report, nil233222 }234223 235224 func intPtrFromEloReport(elo difficulty.Elo, confidence difficulty.Confidence) *int {
M cmd/new_export_test.go +3 -3
M cmd/new_spawn_test.go +43 -0
@@ -83,6 +83,49 @@ func TestSpawnFromModeUsesPresetEloWhenDifficultyOmitted(t *testing.T) {
8383 }8484 }8585 86+func TestSpawnFromVariantUsesDefaultEloWhenDifficultyOmitted(t *testing.T) {87+ defaultElo := difficulty.Elo(1200)88+ spawner := &testEloSpawner{}89+ variant := registry.VariantEntry{90+ Definition: puzzle.VariantDef{Title: "Rule", DefaultElo: defaultElo},91+ Elo: spawner,92+ }93+94+ _, report, err := spawnFromVariant(variant, "", nil, "generated-name")95+ if err != nil {96+ t.Fatalf("spawnFromVariant returned error: %v", err)97+ }98+ if !spawner.called {99+ t.Fatal("expected SpawnElo to be called")100+ }101+ if spawner.seed != "generated-name" {102+ t.Fatalf("Elo seed = %q, want generated save name", spawner.seed)103+ }104+ if spawner.elo != defaultElo || report.TargetElo != defaultElo || report.Confidence == "" {105+ t.Fatalf("Elo/report = (%d, %#v), want default report", spawner.elo, report)106+ }107+}108+109+func TestSpawnFromVariantExplicitDifficultyOverridesDefault(t *testing.T) {110+ explicit := difficulty.Elo(2200)111+ spawner := &testEloSpawner{}112+ variant := registry.VariantEntry{113+ Definition: puzzle.VariantDef{Title: "Rule", DefaultElo: 800},114+ Elo: spawner,115+ }116+117+ _, _, err := spawnFromVariant(variant, "user-seed", &explicit, "generated-name")118+ if err != nil {119+ t.Fatalf("spawnFromVariant returned error: %v", err)120+ }121+ if spawner.elo != explicit {122+ t.Fatalf("Elo = %d, want explicit %d", spawner.elo, explicit)123+ }124+ if spawner.seed != "user-seed" {125+ t.Fatalf("seed = %q, want explicit seed", spawner.seed)126+ }127+}128+86129 func TestSpawnFromModeExplicitDifficultyOverridesPreset(t *testing.T) {87130 preset := difficulty.Elo(800)88131 explicit := difficulty.Elo(2200)
M cmd/test.go +1 -1
M daily/daily_test.go +3 -5
M export/pack/packexport_test.go +20 -1
M export/pack/render.go +2 -2
M export/pack/spec.go +65 -44
@@ -25,6 +25,8 @@ var Version = "dev"
2525 2626 var nowFn = time.Now2727 28+const PackSpecSchemaV2 = "puzzletea.pack.v2"29+2830 const (2931 DefaultTitle = "PuzzleTea Sampler"3032 DefaultHeader = "A small mixed pack that highlights a few PuzzleTea puzzle styles."
@@ -33,6 +35,7 @@ const (
3335 )3436 3537 type Spec struct {38+ Schema string `json:"schema,omitempty"`3639 Title string `json:"title,omitempty"`3740 Header string `json:"header,omitempty"`3841 Advert string `json:"advert,omitempty"`
@@ -88,6 +91,7 @@ func DefaultSpec(cwd string) Spec {
8891 }8992 9093 spec := Spec{94+ Schema: PackSpecSchemaV2,9195 Title: DefaultTitle,9296 Header: DefaultHeader,9397 Advert: DefaultAdvert,
@@ -97,14 +101,14 @@ func DefaultSpec(cwd string) Spec {
97101 Counts: zeroCounts(),98102 }99103 100- setDefaultCount(spec.Counts, "Sudoku", "Easy", 2)101- setDefaultCount(spec.Counts, "Word Search", "Easy 10x10", 2)102- setDefaultCount(spec.Counts, "Hitori", "Easy", 1)103- setDefaultCount(spec.Counts, "Nonogram", "Standard", 1)104- setDefaultCount(spec.Counts, "Nurikabe", "Easy", 1)105- setDefaultCount(spec.Counts, "Takuzu", "Easy", 1)106- setDefaultCount(spec.Counts, "Shikaku", "Easy 7x7", 1)107- setDefaultCount(spec.Counts, "Spell Puzzle", "Easy", 1)104+ setDefaultCount(spec.Counts, "Sudoku", "Sudoku", 2)105+ setDefaultCount(spec.Counts, "Word Search", "Word Search", 2)106+ setDefaultCount(spec.Counts, "Hitori", "Hitori", 1)107+ setDefaultCount(spec.Counts, "Nonogram", "Nonogram", 1)108+ setDefaultCount(spec.Counts, "Nurikabe", "Nurikabe", 1)109+ setDefaultCount(spec.Counts, "Takuzu", "Takuzu", 1)110+ setDefaultCount(spec.Counts, "Shikaku", "Shikaku", 1)111+ setDefaultCount(spec.Counts, "Spell Puzzle", "Spell Puzzle", 1)108112 109113 return spec110114 }
@@ -117,15 +121,16 @@ func ExportCatalog() []GameCatalog {
117121 continue118122 }119123 120- gameModes := make([]ModeCatalog, 0, len(entry.Modes))121- for _, mode := range entry.Modes {124+ gameModes := make([]ModeCatalog, 0, len(entry.Variants))125+ for _, variant := range entry.Variants {126+ elo := int(variant.Definition.DefaultElo)122127 gameModes = append(gameModes, ModeCatalog{123128 GameID: entry.Definition.ID,124129 GameType: entry.Definition.Name,125- ModeID: mode.Definition.ID,126- ModeTitle: mode.Definition.Title,127- Seeded: mode.Seeded != nil,128- PresetElo: intPtrFromElo(mode.Definition.PresetElo),130+ ModeID: puzzle.ModeID(variant.Definition.ID),131+ ModeTitle: variant.Definition.Title,132+ Seeded: variant.Seeded != nil,133+ PresetElo: &elo,129134 })130135 }131136
@@ -276,6 +281,9 @@ func setDefaultCount(counts map[puzzle.GameID]map[puzzle.ModeID]int, gameName, m
276281 func buildRunPlan(spec Spec) (runPlan, error) {277282 spec = normalizeSpec(spec)278283 284+ if spec.Schema != PackSpecSchemaV2 {285+ return runPlan{}, fmt.Errorf("unsupported pack spec schema %q", spec.Schema)286+ }279287 if !strings.EqualFold(filepath.Ext(spec.PDFOutputPath), ".pdf") {280288 return runPlan{}, fmt.Errorf("pdf output path must use a .pdf extension")281289 }
@@ -313,9 +321,13 @@ func buildRunPlan(spec Spec) (runPlan, error) {
313321 continue314322 }315323 316- modeByID := make(map[puzzle.ModeID]registry.ModeEntry, len(entry.Modes))317- for _, mode := range entry.Modes {318- modeByID[mode.Definition.ID] = mode324+ variantByID := make(map[puzzle.ModeID]registry.VariantEntry, len(entry.Variants))325+ for _, variant := range entry.Variants {326+ variantByID[puzzle.ModeID(variant.Definition.ID)] = variant327+ }328+ legacyByID := make(map[puzzle.ModeID]puzzle.LegacyModeAlias, len(entry.LegacyModes))329+ for _, alias := range entry.LegacyModes {330+ legacyByID[alias.ID] = alias319331 }320332 321333 for modeID, count := range modes {
@@ -326,25 +338,25 @@ func buildRunPlan(spec Spec) (runPlan, error) {
326338 continue327339 }328340 329- mode, ok := modeByID[modeID]341+ variant, targetElo, ok := exportVariantForModeID(entry, variantByID, legacyByID, modeID)330342 if !ok {331- return runPlan{}, fmt.Errorf("%s / %s is not a known export mode", entry.Definition.Name, modeID)343+ return runPlan{}, fmt.Errorf("%s / %s is not a known export variant", entry.Definition.Name, modeID)332344 }333- if strings.TrimSpace(spec.Seed) != "" && mode.Seeded == nil {334- return runPlan{}, fmt.Errorf("%s / %s does not support seeded export", entry.Definition.Name, mode.Definition.Title)345+ if strings.TrimSpace(spec.Seed) != "" && variant.Seeded == nil {346+ return runPlan{}, fmt.Errorf("%s / %s does not support seeded export", entry.Definition.Name, variant.Definition.Title)335347 }336348 337349 total += count338350 selections = append(selections, modeSelection{339351 gameID: entry.Definition.ID,340352 gameType: entry.Definition.Name,341- modeID: mode.Definition.ID,342- modeTitle: mode.Definition.Title,353+ modeID: puzzle.ModeID(variant.Definition.ID),354+ modeTitle: variant.Definition.Title,343355 count: count,344- spawner: mode.Spawner,345- seededSpawner: mode.Seeded,346- eloSpawner: mode.Elo,347- targetElo: cloneElo(mode.Definition.PresetElo),356+ spawner: variant.Seeded,357+ seededSpawner: variant.Seeded,358+ eloSpawner: variant.Elo,359+ targetElo: &targetElo,348360 })349361 }350362 }
@@ -366,7 +378,29 @@ func buildRunPlan(spec Spec) (runPlan, error) {
366378 }, nil367379 }368380 381+func exportVariantForModeID(382+ entry registry.Entry,383+ variantByID map[puzzle.ModeID]registry.VariantEntry,384+ legacyByID map[puzzle.ModeID]puzzle.LegacyModeAlias,385+ modeID puzzle.ModeID,386+) (registry.VariantEntry, difficulty.Elo, bool) {387+ if variant, ok := variantByID[modeID]; ok {388+ return variant, variant.Definition.DefaultElo, true389+ }390+ alias, ok := legacyByID[modeID]391+ if !ok {392+ return registry.VariantEntry{}, 0, false393+ }394+ for _, variant := range entry.Variants {395+ if variant.Definition.ID == alias.TargetVariantID {396+ return variant, alias.PresetElo, true397+ }398+ }399+ return registry.VariantEntry{}, 0, false400+}401+369402 func normalizeSpec(spec Spec) Spec {403+ spec.Schema = strings.TrimSpace(spec.Schema)370404 spec.Title = strings.TrimSpace(spec.Title)371405 spec.Header = strings.TrimSpace(spec.Header)372406 spec.Advert = strings.TrimSpace(spec.Advert)
@@ -374,6 +408,9 @@ func normalizeSpec(spec Spec) Spec {
374408 spec.PDFOutputPath = strings.TrimSpace(spec.PDFOutputPath)375409 spec.JSONLOutputPath = strings.TrimSpace(spec.JSONLOutputPath)376410 411+ if spec.Schema == "" {412+ spec.Schema = PackSpecSchemaV2413+ }377414 if spec.Volume == 0 {378415 spec.Volume = 1379416 }
@@ -427,7 +464,7 @@ func generateRecords(
427464 }428465 429466 records = append(records, pdfexport.JSONLRecord{430- Schema: pdfexport.ExportSchemaV1,467+ Schema: pdfexport.ExportSchemaV2,431468 Pack: pdfexport.JSONLPackMeta{432469 Generated: generatedAt.Format(time.RFC3339),433470 Version: Version,
@@ -453,22 +490,6 @@ func generateRecords(
453490 return records, nil454491 }455492 456-func intPtrFromElo(elo *difficulty.Elo) *int {457- if elo == nil {458- return nil459- }460- v := int(*elo)461- return &v462-}463-464-func cloneElo(elo *difficulty.Elo) *difficulty.Elo {465- if elo == nil {466- return nil467- }468- v := *elo469- return &v470-}471-472493 func intPtrFromReportElo(elo difficulty.Elo, confidence difficulty.Confidence) *int {473494 if confidence == "" {474495 return nil
M export/pdf/jsonl.go +14 -2
M gameentry/gameentry.go +169 -19
@@ -4,6 +4,7 @@ package gameentry
44 55 import (66 "fmt"7+ "math/rand/v2"78 89 "github.com/FelineStateMachine/puzzletea/difficulty"910 "github.com/FelineStateMachine/puzzletea/export/pdf"
@@ -18,12 +19,20 @@ type ModeEntry struct {
1819 Elo game.EloSpawner1920 }2021 22+type VariantEntry struct {23+ Definition puzzle.VariantDef24+ Elo game.EloSpawner25+ Seeded game.SeededSpawner26+}27+2128 type Entry struct {22- Definition puzzle.Definition23- Help string24- Import func([]byte) (game.Gamer, error)25- Modes []ModeEntry26- Print pdfexport.PrintAdapter29+ Definition puzzle.Definition30+ Help string31+ Import func([]byte) (game.Gamer, error)32+ Variants []VariantEntry33+ LegacyModes []puzzle.LegacyModeAlias34+ Modes []ModeEntry35+ Print pdfexport.PrintAdapter2736 }2837 2938 type EntrySpec struct {
@@ -49,12 +58,47 @@ func BuildModeDefs(modes []game.Mode) []puzzle.ModeDef {
4958 return defs5059 }5160 61+func BuildDefaultVariantDef(title, description string, defaultElo difficulty.Elo) puzzle.VariantDef {62+ return puzzle.NewVariantDef(puzzle.VariantSpec{63+ Title: title,64+ Description: description,65+ DefaultElo: defaultElo,66+ })67+}68+69+func BuildLegacyModeAliases(modes []puzzle.ModeDef, target puzzle.VariantID) []puzzle.LegacyModeAlias {70+ aliases := make([]puzzle.LegacyModeAlias, 0, len(modes))71+ for _, mode := range modes {72+ if mode.PresetElo == nil {73+ continue74+ }75+ aliases = append(aliases, puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{76+ ID: mode.ID,77+ Title: mode.Title,78+ Description: mode.Description,79+ TargetVariantID: target,80+ PresetElo: *mode.PresetElo,81+ }))82+ }83+ return aliases84+}85+5286 func NewEntry(spec EntrySpec) Entry {53- if len(spec.Definition.Modes) != len(spec.Modes) {87+ definition := spec.Definition88+ if len(definition.Variants) == 0 && hasEloMode(definition.Modes) {89+ definition.Variants = []puzzle.VariantDef{90+ BuildDefaultVariantDef(definition.Name, definition.Description, defaultEloForModes(definition.Modes)),91+ }92+ }93+ if len(definition.LegacyModes) == 0 && len(definition.Variants) == 1 {94+ definition.LegacyModes = BuildLegacyModeAliases(definition.Modes, definition.Variants[0].ID)95+ }96+97+ if len(definition.Modes) != len(spec.Modes) {5498 panic(fmt.Sprintf(5599 "gameentry: definition %q has %d mode definitions but %d runtime modes",56- spec.Definition.Name,57- len(spec.Definition.Modes),100+ definition.Name,101+ len(definition.Modes),58102 len(spec.Modes),59103 ))60104 }
@@ -65,16 +109,16 @@ func NewEntry(spec EntrySpec) Entry {
65109 if !ok {66110 panic(fmt.Sprintf(67111 "gameentry: definition %q mode %q does not implement game.Spawner",68- spec.Definition.Name,112+ definition.Name,69113 mode.Title(),70114 ))71115 }72116 73- modeDef := spec.Definition.Modes[i]117+ modeDef := definition.Modes[i]74118 if modeDef.Title != mode.Title() || modeDef.Description != mode.Description() {75119 panic(fmt.Sprintf(76120 "gameentry: definition %q mode %d metadata does not match runtime mode %q",77- spec.Definition.Name,121+ definition.Name,78122 i,79123 mode.Title(),80124 ))
@@ -93,7 +137,7 @@ func NewEntry(spec EntrySpec) Entry {
93137 if entry.Definition.Seeded != (entry.Seeded != nil) {94138 panic(fmt.Sprintf(95139 "gameentry: definition %q mode %q seeded flag does not match runtime mode",96- spec.Definition.Name,140+ definition.Name,97141 modeDef.Title,98142 ))99143 }
@@ -101,7 +145,7 @@ func NewEntry(spec EntrySpec) Entry {
101145 if err := difficulty.ValidateElo(*entry.Definition.PresetElo); err != nil {102146 panic(fmt.Sprintf(103147 "gameentry: definition %q mode %q preset Elo is invalid: %v",104- spec.Definition.Name,148+ definition.Name,105149 modeDef.Title,106150 err,107151 ))
@@ -109,7 +153,7 @@ func NewEntry(spec EntrySpec) Entry {
109153 if entry.Elo == nil {110154 panic(fmt.Sprintf(111155 "gameentry: definition %q mode %q has preset Elo but runtime mode does not implement game.EloSpawner",112- spec.Definition.Name,156+ definition.Name,113157 modeDef.Title,114158 ))115159 }
@@ -117,11 +161,117 @@ func NewEntry(spec EntrySpec) Entry {
117161 entries = append(entries, entry)118162 }119163 164+ variants := buildVariantEntries(definition, entries)165+120166 return Entry{121- Definition: spec.Definition,122- Help: spec.Help,123- Import: spec.Import,124- Modes: entries,125- Print: spec.Print,167+ Definition: definition,168+ Help: spec.Help,169+ Import: spec.Import,170+ Variants: variants,171+ LegacyModes: append([]puzzle.LegacyModeAlias(nil), definition.LegacyModes...),172+ Modes: entries,173+ Print: spec.Print,174+ }175+}176+177+func hasEloMode(modes []puzzle.ModeDef) bool {178+ for _, mode := range modes {179+ if mode.PresetElo != nil {180+ return true181+ }182+ }183+ return false184+}185+186+func defaultEloForModes(modes []puzzle.ModeDef) difficulty.Elo {187+ presets := make([]difficulty.Elo, 0, len(modes))188+ for _, mode := range modes {189+ if mode.PresetElo != nil {190+ presets = append(presets, *mode.PresetElo)191+ }192+ }193+ if len(presets) == 0 {194+ return 1200195+ }196+ return presets[len(presets)/2]197+}198+199+func buildVariantEntries(def puzzle.Definition, modes []ModeEntry) []VariantEntry {200+ if len(def.Variants) == 0 {201+ return nil202+ }203+ result := make([]VariantEntry, 0, len(def.Variants))204+ for _, variant := range def.Variants {205+ spawners := make([]ModeEntry, 0, len(modes))206+ for _, mode := range modes {207+ if mode.Elo != nil {208+ spawners = append(spawners, mode)209+ }210+ }211+ if len(spawners) == 0 {212+ panic(fmt.Sprintf(213+ "gameentry: definition %q variant %q has no Elo-capable legacy modes",214+ def.Name,215+ variant.Title,216+ ))217+ }218+ elo := variantEloSpawner{219+ defaultElo: variant.DefaultElo,220+ modes: spawners,221+ }222+ result = append(result, VariantEntry{223+ Definition: variant,224+ Elo: elo,225+ Seeded: variantSeededSpawner{226+ elo: elo,227+ defaultElo: variant.DefaultElo,228+ },229+ })230+ }231+ return result232+}233+234+type variantEloSpawner struct {235+ defaultElo difficulty.Elo236+ modes []ModeEntry237+}238+239+func (s variantEloSpawner) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {240+ if err := difficulty.ValidateElo(elo); err != nil {241+ return nil, difficulty.Report{}, err242+ }243+ mode := s.modeForElo(elo)244+ return mode.Elo.SpawnElo(seed, elo)245+}246+247+func (s variantEloSpawner) modeForElo(elo difficulty.Elo) ModeEntry {248+ if len(s.modes) == 1 {249+ return s.modes[0]250+ }251+ band := int(elo) * len(s.modes) / (int(difficulty.SoftCapElo) + 1)252+ if band < 0 {253+ band = 0254+ }255+ if band >= len(s.modes) {256+ band = len(s.modes) - 1257+ }258+ return s.modes[band]259+}260+261+type variantSeededSpawner struct {262+ elo game.EloSpawner263+ defaultElo difficulty.Elo264+}265+266+func (s variantSeededSpawner) Spawn() (game.Gamer, error) {267+ return s.SpawnSeeded(rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())))268+}269+270+func (s variantSeededSpawner) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {271+ if rng == nil {272+ return nil, fmt.Errorf("nil RNG")126273 }274+ seed := fmt.Sprintf("variant-seeded:%016x:%016x", rng.Uint64(), rng.Uint64())275+ g, _, err := s.elo.SpawnElo(seed, s.defaultElo)276+ return g, err127277 }
M games/nurikabe/generator.go +8 -1
M puzzle/puzzle.go +81 -0
@@ -18,6 +18,40 @@ type ModeDef struct {
1818 PresetElo *difficulty.Elo1919 }2020 21+type VariantID string22+23+type VariantDef struct {24+ ID VariantID25+ Title string26+ Description string27+ DefaultElo difficulty.Elo28+}29+30+type VariantSpec struct {31+ ID VariantID32+ Title string33+ Description string34+ DefaultElo difficulty.Elo35+}36+37+type LegacyModeAlias struct {38+ ID ModeID39+ Title string40+ Description string41+ TargetVariantID VariantID42+ PresetElo difficulty.Elo43+ CLIAliases []string44+}45+46+type LegacyModeAliasSpec struct {47+ ID ModeID48+ Title string49+ Description string50+ TargetVariantID VariantID51+ PresetElo difficulty.Elo52+ CLIAliases []string53+}54+2155 type ModeSpec struct {2256 ID ModeID2357 Title string
@@ -31,6 +65,8 @@ type Definition struct {
3165 Name string3266 Description string3367 Aliases []string68+ Variants []VariantDef69+ LegacyModes []LegacyModeAlias3470 Modes []ModeDef3571 DailyModeIDs []ModeID3672 }
@@ -40,6 +76,8 @@ type DefinitionSpec struct {
4076 Name string4177 Description string4278 Aliases []string79+ Variants []VariantDef80+ LegacyModes []LegacyModeAlias4381 Modes []ModeDef4482 DailyModeIDs []ModeID4583 }
@@ -58,6 +96,34 @@ func NewModeDef(spec ModeSpec) ModeDef {
5896 }5997 }6098 99+func NewVariantDef(spec VariantSpec) VariantDef {100+ id := spec.ID101+ if id == "" {102+ id = CanonicalVariantID(spec.Title)103+ }104+ return VariantDef{105+ ID: id,106+ Title: spec.Title,107+ Description: spec.Description,108+ DefaultElo: spec.DefaultElo,109+ }110+}111+112+func NewLegacyModeAlias(spec LegacyModeAliasSpec) LegacyModeAlias {113+ id := spec.ID114+ if id == "" {115+ id = CanonicalModeID(spec.Title)116+ }117+ return LegacyModeAlias{118+ ID: id,119+ Title: spec.Title,120+ Description: spec.Description,121+ TargetVariantID: spec.TargetVariantID,122+ PresetElo: spec.PresetElo,123+ CLIAliases: append([]string(nil), spec.CLIAliases...),124+ }125+}126+61127 func NewDefinition(spec DefinitionSpec) Definition {62128 id := spec.ID63129 if id == "" {
@@ -68,6 +134,8 @@ func NewDefinition(spec DefinitionSpec) Definition {
68134 Name: spec.Name,69135 Description: spec.Description,70136 Aliases: append([]string(nil), spec.Aliases...),137+ Variants: append([]VariantDef(nil), spec.Variants...),138+ LegacyModes: append([]LegacyModeAlias(nil), spec.LegacyModes...),71139 Modes: append([]ModeDef(nil), spec.Modes...),72140 DailyModeIDs: append([]ModeID(nil), spec.DailyModeIDs...),73141 }
@@ -107,6 +175,10 @@ func CanonicalModeID(title string) ModeID {
107175 return ModeID(NormalizeName(title))108176 }109177 178+func CanonicalVariantID(title string) VariantID {179+ return VariantID(NormalizeName(title))180+}181+110182 func (d Definition) HasMode(id ModeID) bool {111183 for _, mode := range d.Modes {112184 if mode.ID == id {
@@ -115,3 +187,12 @@ func (d Definition) HasMode(id ModeID) bool {
115187 }116188 return false117189 }190+191+func (d Definition) HasVariant(id VariantID) bool {192+ for _, variant := range d.Variants {193+ if variant.ID == id {194+ return true195+ }196+ }197+ return false198+}
M registry/registry.go +13 -10
M registry/registry_test.go +25 -0
M resolve/resolve.go +86 -5
@@ -7,6 +7,7 @@ import (
77 "math/rand/v2"88 "strings"99 10+ "github.com/FelineStateMachine/puzzletea/difficulty"1011 "github.com/FelineStateMachine/puzzletea/game"1112 "github.com/FelineStateMachine/puzzletea/puzzle"1213 "github.com/FelineStateMachine/puzzletea/registry"
@@ -39,6 +40,78 @@ func ModeEntry(entry registry.Entry, name string) (registry.ModeEntry, error) {
3940 name, entry.Definition.Name, strings.Join(ModeNames(entry), "\n "))4041 }4142 43+type VariantSelection struct {44+ Variant registry.VariantEntry45+ ExplicitElo *difficulty.Elo46+ LegacyAlias *puzzle.LegacyModeAlias47+ DisplayTitle string48+}49+50+func VariantEntry(entry registry.Entry, name string) (VariantSelection, error) {51+ if len(entry.Variants) == 0 {52+ return VariantSelection{}, fmt.Errorf("game %q has no available variants", entry.Definition.Name)53+ }54+55+ if name == "" {56+ variant := entry.Variants[0]57+ return VariantSelection{58+ Variant: variant,59+ DisplayTitle: variant.Definition.Title,60+ }, nil61+ }62+63+ norm := Normalize(name)64+ for _, variant := range entry.Variants {65+ if Normalize(variant.Definition.Title) == norm || Normalize(string(variant.Definition.ID)) == norm {66+ return VariantSelection{67+ Variant: variant,68+ DisplayTitle: variant.Definition.Title,69+ }, nil70+ }71+ }72+73+ for _, alias := range entry.LegacyModes {74+ if legacyAliasMatches(alias, norm) {75+ variant, ok := variantByID(entry, alias.TargetVariantID)76+ if !ok {77+ return VariantSelection{}, fmt.Errorf("legacy mode %q targets missing variant %q", alias.Title, alias.TargetVariantID)78+ }79+ elo := alias.PresetElo80+ aliasCopy := alias81+ return VariantSelection{82+ Variant: variant,83+ ExplicitElo: &elo,84+ LegacyAlias: &aliasCopy,85+ DisplayTitle: variant.Definition.Title,86+ }, nil87+ }88+ }89+90+ return VariantSelection{}, fmt.Errorf("unknown variant or legacy mode %q for %s\n\nAvailable variants:\n %s",91+ name, entry.Definition.Name, strings.Join(VariantNames(entry), "\n "))92+}93+94+func legacyAliasMatches(alias puzzle.LegacyModeAlias, norm string) bool {95+ if Normalize(alias.Title) == norm || Normalize(string(alias.ID)) == norm {96+ return true97+ }98+ for _, cliAlias := range alias.CLIAliases {99+ if Normalize(cliAlias) == norm {100+ return true101+ }102+ }103+ return false104+}105+106+func variantByID(entry registry.Entry, id puzzle.VariantID) (registry.VariantEntry, bool) {107+ for _, variant := range entry.Variants {108+ if variant.Definition.ID == id {109+ return variant, true110+ }111+ }112+ return registry.VariantEntry{}, false113+}114+42115 // ModeNames returns the display names of all modes in an entry.43116 func ModeNames(entry registry.Entry) []string {44117 names := make([]string, 0, len(entry.Modes))
@@ -48,6 +121,14 @@ func ModeNames(entry registry.Entry) []string {
48121 return names49122 }50123 124+func VariantNames(entry registry.Entry) []string {125+ names := make([]string, 0, len(entry.Variants))126+ for _, variant := range entry.Variants {127+ names = append(names, variant.Definition.Title)128+ }129+ return names130+}131+51132 // RNGFromString creates a deterministic RNG seeded from an arbitrary string.52133 // The string is hashed with FNV-64a to produce a uint64 seed.53134 func RNGFromString(seed string) *rand.Rand {
@@ -69,8 +150,8 @@ func seededModeForDefinition(seed string, entry registry.Entry) (seededEntry, bo
69150 var bestHash uint6470151 found := false71152 72- for _, mode := range entry.Modes {73- if mode.Seeded == nil {153+ for _, variant := range entry.Variants {154+ if variant.Seeded == nil {74155 continue75156 }76157 h := fnv.New64a()
@@ -78,14 +159,14 @@ func seededModeForDefinition(seed string, entry registry.Entry) (seededEntry, bo
78159 h.Write([]byte{0})79160 h.Write([]byte(entry.Definition.Name))80161 h.Write([]byte{0})81- h.Write([]byte(mode.Definition.Title))162+ h.Write([]byte(variant.Definition.Title))82163 score := h.Sum64()83164 if !found || score > bestHash {84165 bestHash = score85166 best = seededEntry{86- spawner: mode.Seeded,167+ spawner: variant.Seeded,87168 gameType: entry.Definition.Name,88- mode: mode.Definition.Title,169+ mode: variant.Definition.Title,89170 }90171 found = true91172 }
M resolve/resolve_test.go +31 -6
@@ -94,6 +94,32 @@ func TestModeNames(t *testing.T) {
9494 }9595 }9696 97+func TestVariantEntryResolvesVariantsAndLegacyAliases(t *testing.T) {98+ entry, ok := registry.Resolve("Sudoku")99+ if !ok {100+ t.Fatal("missing sudoku entry")101+ }102+103+ variant, err := VariantEntry(entry, "sudoku")104+ if err != nil {105+ t.Fatal(err)106+ }107+ if variant.Variant.Definition.Title != "Sudoku" || variant.ExplicitElo != nil {108+ t.Fatalf("variant selection = %#v, want Sudoku without explicit Elo", variant)109+ }110+111+ legacy, err := VariantEntry(entry, "easy")112+ if err != nil {113+ t.Fatal(err)114+ }115+ if legacy.Variant.Definition.Title != "Sudoku" {116+ t.Fatalf("legacy variant = %q, want Sudoku", legacy.Variant.Definition.Title)117+ }118+ if legacy.ExplicitElo == nil || *legacy.ExplicitElo != 600 {119+ t.Fatalf("legacy explicit Elo = %v, want 600", legacy.ExplicitElo)120+ }121+}122+97123 func TestRNGFromString(t *testing.T) {98124 rng1 := RNGFromString("hello-world")99125 rng2 := RNGFromString("hello-world")
@@ -146,13 +172,12 @@ func TestSeededModeStableOnEntryChange(t *testing.T) {
146172 extended := append([]registry.Entry(nil), testEntries...)147173 extended = append(extended, registry.Entry{148174 Definition: puzzle.Definition{149- ID: puzzle.CanonicalGameID("Synthetic"),150- Name: "Synthetic",151- Modes: []puzzle.ModeDef{{ID: puzzle.CanonicalModeID("Easy"), Title: "Easy", Seeded: true}},175+ ID: puzzle.CanonicalGameID("Synthetic"),176+ Name: "Synthetic",177+ Variants: []puzzle.VariantDef{{ID: puzzle.CanonicalVariantID("Synthetic"), Title: "Synthetic"}},152178 },153- Modes: []registry.ModeEntry{{154- Definition: puzzle.ModeDef{ID: puzzle.CanonicalModeID("Easy"), Title: "Easy", Seeded: true},155- Spawner: lightsout.Modes[0].(game.Spawner),179+ Variants: []registry.VariantEntry{{180+ Definition: puzzle.VariantDef{ID: puzzle.CanonicalVariantID("Synthetic"), Title: "Synthetic"},156181 Seeded: lightsout.Modes[0].(game.SeededSpawner),157182 }},158183 })
M session/session.go +2 -0
M stats/stats.go +12 -5
M store/game_record.go +2 -0
M store/migrate.go +61 -2
@@ -5,7 +5,7 @@ import (
55 "fmt"66 )77 8-const currentSchemaVersion = 48+const currentSchemaVersion = 599 1010 type migration struct {1111 version int
@@ -18,6 +18,7 @@ var schemaMigrations = []migration{
1818 {version: 2, name: "add game metadata columns", apply: migrateGameMetadata},1919 {version: 3, name: "refresh stats views", apply: refreshStatsViews},2020 {version: 4, name: "add difficulty metadata", apply: migrateDifficultyMetadata},21+ {version: 5, name: "add variant metadata", apply: migrateVariantMetadata},2122 }2223 2324 type gameRowMeta struct {
@@ -137,8 +138,13 @@ func detectSchemaVersion(db *sql.DB) (int, error) {
137138 return 2, nil138139 }139140 }141+ for _, column := range []string{"variant_id", "variant"} {142+ if !columns[column] {143+ return 4, nil144+ }145+ }140146 141- return 4, nil147+ return 5, nil142148 }143149 144150 func createGamesSchema(db *sql.DB) error {
@@ -181,12 +187,24 @@ func migrateDifficultyMetadata(db *sql.DB) error {
181187 return refreshStatsViews(db)182188 }183189 190+func migrateVariantMetadata(db *sql.DB) error {191+ if err := ensureVariantColumns(db); err != nil {192+ return err193+ }194+ if err := backfillVariantMetadata(db); err != nil {195+ return err196+ }197+ return refreshStatsViews(db)198+}199+184200 func ensureGameColumns(db *sql.DB) error {185201 columns := []struct {186202 name string187203 definition string188204 }{189205 {name: "game_id", definition: "TEXT NOT NULL DEFAULT ''"},206+ {name: "variant_id", definition: "TEXT NOT NULL DEFAULT ''"},207+ {name: "variant", definition: "TEXT NOT NULL DEFAULT ''"},190208 {name: "mode_id", definition: "TEXT NOT NULL DEFAULT ''"},191209 {name: "run_kind", definition: "TEXT NOT NULL DEFAULT 'normal'"},192210 {name: "run_date", definition: "DATE"},
@@ -211,6 +229,30 @@ func ensureGameColumns(db *sql.DB) error {
211229 return nil212230 }213231 232+func ensureVariantColumns(db *sql.DB) error {233+ columns := []struct {234+ name string235+ definition string236+ }{237+ {name: "variant_id", definition: "TEXT NOT NULL DEFAULT ''"},238+ {name: "variant", definition: "TEXT NOT NULL DEFAULT ''"},239+ }240+241+ existing, err := tableColumns(db, "games")242+ if err != nil {243+ return err244+ }245+ for _, column := range columns {246+ if existing[column.name] {247+ continue248+ }249+ if _, err := db.Exec(fmt.Sprintf("ALTER TABLE games ADD COLUMN %s %s", column.name, column.definition)); err != nil {250+ return fmt.Errorf("adding games.%s: %w", column.name, err)251+ }252+ }253+ return nil254+}255+214256 func ensureDifficultyColumns(db *sql.DB) error {215257 columns := []struct {216258 name string
@@ -236,6 +278,23 @@ func ensureDifficultyColumns(db *sql.DB) error {
236278 return nil237279 }238280 281+func backfillVariantMetadata(db *sql.DB) error {282+ _, err := db.Exec(`283+UPDATE games284+ SET variant = CASE WHEN variant = '' THEN mode ELSE variant END,285+ variant_id = CASE286+ WHEN variant_id != '' THEN variant_id287+ WHEN mode_id != '' THEN mode_id288+ ELSE lower(trim(replace(replace(mode, '-', ' '), '_', ' ')))289+ END290+ WHERE variant = ''291+ OR variant_id = ''`)292+ if err != nil {293+ return fmt.Errorf("backfilling variant metadata: %w", err)294+ }295+ return nil296+}297+239298 func tableColumns(db *sql.DB, table string) (map[string]bool, error) {240299 rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))241300 if err != nil {
M store/store.go +18 -10
@@ -18,6 +18,8 @@ CREATE TABLE IF NOT EXISTS games (
1818 name TEXT NOT NULL UNIQUE,1919 game_id TEXT NOT NULL DEFAULT '',2020 game_type TEXT NOT NULL,21+ variant_id TEXT NOT NULL DEFAULT '',22+ variant TEXT NOT NULL DEFAULT '',2123 mode_id TEXT NOT NULL DEFAULT '',2224 mode TEXT NOT NULL,2325 initial_state TEXT NOT NULL,
@@ -49,11 +51,11 @@ SELECT
4951 COUNT(*) FILTER (WHERE run_kind = 'daily') AS times_daily,5052 COUNT(*) FILTER (WHERE status = 'completed'5153 AND run_kind = 'daily') AS daily_victories,52- (SELECT mode FROM games g254+ (SELECT COALESCE(NULLIF(variant, ''), mode) FROM games g25355 WHERE g2.game_type = games.game_type5456 AND g2.status = 'completed'55- GROUP BY mode56- ORDER BY COUNT(*) DESC, mode ASC57+ GROUP BY COALESCE(NULLIF(variant, ''), mode)58+ ORDER BY COUNT(*) DESC, COALESCE(NULLIF(variant, ''), mode) ASC5759 LIMIT 1) AS preferred_mode5860 FROM games5961 GROUP BY game_type;`
@@ -62,7 +64,7 @@ const createModeStatsViewSQL = `
6264 CREATE VIEW IF NOT EXISTS mode_stats AS6365 SELECT6466 game_type,65- mode,67+ COALESCE(NULLIF(variant, ''), mode) AS mode,6668 COUNT(*) FILTER (WHERE status = 'completed') AS victories,6769 COUNT(*) FILTER (WHERE status = 'completed'6870 AND run_kind = 'daily') AS daily_victories,
@@ -70,13 +72,13 @@ SELECT
7072 FILTER (WHERE status = 'completed'7173 AND COALESCE(actual_difficulty_elo, target_difficulty_elo) IS NOT NULL)) AS INTEGER) AS difficulty_elo7274 FROM games73-GROUP BY game_type, mode;`75+GROUP BY game_type, COALESCE(NULLIF(variant, ''), mode);`7476 7577 type Store struct {7678 db *sql.DB7779 }7880 79-const gameSelectColumns = `id, name, game_id, game_type, mode_id, mode, initial_state, save_state,81+const gameSelectColumns = `id, name, game_id, game_type, variant_id, variant, mode_id, mode, initial_state, save_state,8082 status, run_kind, run_date, week_year, week_number, week_index, seed_text,8183 target_difficulty_elo, actual_difficulty_elo, difficulty_confidence,8284 created_at, updated_at, completed_at`
@@ -97,7 +99,7 @@ func scanGameRecord(scanner rowScanner) (GameRecord, error) {
9799 var difficultyConfidence sql.NullString98100 var completedAt sql.NullTime99101 if err := scanner.Scan(100- &g.ID, &g.Name, &g.GameID, &g.GameType, &g.ModeID, &g.Mode,102+ &g.ID, &g.Name, &g.GameID, &g.GameType, &g.VariantID, &g.Variant, &g.ModeID, &g.Mode,101103 &g.InitialState, &g.SaveState, &g.Status,102104 &g.RunKind, &runDate, &weekYear, &weekNumber, &weekIndex, &seedText,103105 &targetDifficultyElo, &actualDifficultyElo, &difficultyConfidence,
@@ -201,17 +203,23 @@ func (s *Store) CreateGame(rec *GameRecord) error {
201203 if rec.ModeID == "" {202204 rec.ModeID = CanonicalModeID(rec.Mode)203205 }206+ if rec.Variant == "" {207+ rec.Variant = rec.Mode208+ }209+ if rec.VariantID == "" {210+ rec.VariantID = CanonicalModeID(rec.Variant)211+ }204212 if rec.RunKind == "" {205213 rec.RunKind = RunKindNormal206214 }207215 208216 result, err := s.db.Exec(209217 `INSERT INTO games210- (name, game_id, game_type, mode_id, mode, initial_state, save_state, status,218+ (name, game_id, game_type, variant_id, variant, mode_id, mode, initial_state, save_state, status,211219 run_kind, run_date, week_year, week_number, week_index, seed_text,212220 target_difficulty_elo, actual_difficulty_elo, difficulty_confidence)213- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,214- rec.Name, rec.GameID, rec.GameType, rec.ModeID, rec.Mode,221+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,222+ rec.Name, rec.GameID, rec.GameType, rec.VariantID, rec.Variant, rec.ModeID, rec.Mode,215223 rec.InitialState, rec.SaveState, rec.Status, string(rec.RunKind), rec.RunDate,216224 nullableInt(rec.WeekYear), nullableInt(rec.WeekNumber), nullableInt(rec.WeekIndex), nullableString(rec.SeedText),217225 nullableIntPtr(rec.TargetDifficultyElo), nullableIntPtr(rec.ActualDifficultyElo), nullableString(rec.DifficultyConfidence),
M store/store_test.go +12 -0
M weekly/weekly_test.go +3 -5