- commit
- 23f4ddd1fcfe76627de3befa727be0527c05f9ce
- parent
- b28424a4feee29ba9f20b1dca3cf87e490c85732
- author
- Dami <dami.etoile@gmail.com>
@@ -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 }@@ -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+}@@ -25,7 +25,7 @@ func TestHandleExportEnterLoadsPersistedDefaults(t *testing.T) {
2525 PDFOutputPath: filepath.Join(tmp, "saved.pdf"),2626 Counts: map[puzzle.GameID]map[puzzle.ModeID]int{2727 puzzle.CanonicalGameID("Sudoku"): {28- puzzle.CanonicalModeID("Easy"): 4,28+ puzzle.CanonicalModeID("Sudoku"): 4,2929 },3030 },3131 },@@ -22,7 +22,7 @@ func (m model) handleCreateGenerate() (model, tea.Cmd) {
2222 }2323 2424 leaf := leaves[m.createChoiceIndex(len(leaves))]25- spawner, modeTitle, err := m.create.resolveLeafMode(leaf, elo)25+ spawner, modeTitle, err := m.create.resolveLeafVariant(leaf, elo)2626 if err != nil {2727 return m.setErrorf("Could not prepare puzzle: %v", err), nil2828 }@@ -157,20 +157,15 @@ func createTestState(leaves []createLeaf, elo int) createState {
157157 func createTestLeaf(id, gameType, modeTitle string) createLeaf {158158 spawner := createTestEloSpawner{}159159 return createLeaf{160- id: id,161- title: modeTitle,162- gameType: gameType,163- modeTitle: modeTitle,164- modes: []registry.ModeEntry{{165- Definition: puzzle.ModeDef{166- Title: modeTitle,167- PresetElo: ptrElo(1200),160+ id: id,161+ title: modeTitle,162+ gameType: gameType,163+ variant: registry.VariantEntry{164+ Definition: puzzle.VariantDef{165+ Title: modeTitle,166+ DefaultElo: 1200,168167 },169168 Elo: spawner,170- }},169+ },171170 }172171 }173-174-func ptrElo(elo difficulty.Elo) *difficulty.Elo {175- return &elo176-}@@ -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 }@@ -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")@@ -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 @@ -553,7 +553,7 @@ func renderCreateTreeLine(state createState, item createVisibleNode, cursor bool
553553 marker = "[-] "554554 }555555 count := ""556- if total > 0 {556+ if total > 1 {557557 count = " " + ui.DimItemStyle().Render(strconv.Itoa(selected)+"/"+strconv.Itoa(total))558558 }559559 return cursorText + prefix + marker + node.title + count@@ -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 }@@ -142,7 +142,7 @@ func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
142142 switch {143143 case key.Matches(keyMsg, rootKeys.Enter):144144 item := unwrapModeDisplayItem(s.list.SelectedItem())145- mode, ok := item.(registry.ModeEntry)145+ mode, ok := item.(registry.VariantEntry)146146 if !ok {147147 return s, nil, nil148148 }@@ -159,7 +159,7 @@ func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
159159 160160 func (s modeSelectScreen) View(notice noticeState) string {161161 return renderPanelView(s.width, s.height, notice,162- s.entry.Definition.Name+" — Select Mode",162+ s.entry.Definition.Name+" - Select Variant",163163 s.list.View(),164164 "↑/↓ navigate • enter select • esc back",165165 )@@ -23,18 +23,11 @@ func buildSeedModeOptions(definitions []puzzle.Definition) []seedModeOption {
2323 continue2424 }2525 26- for _, mode := range def.Modes {27- if !mode.Seeded {28- continue29- }30-31- options = append(options, seedModeOption{32- key: seedModeKey(def.Name, ""),33- label: def.Name,34- gameType: def.Name,35- })36- break37- }26+ options = append(options, seedModeOption{27+ key: seedModeKey(def.Name, ""),28+ label: def.Name,29+ gameType: def.Name,30+ })3831 }3932 4033 return options@@ -48,12 +41,7 @@ func seedModeKey(gameType, modeTitle string) string {
4841 }4942 5043 func definitionHasSeededModes(def puzzle.Definition) bool {51- for _, mode := range def.Modes {52- if mode.Seeded {53- return true54- }55- }56- return false44+ return len(def.Variants) > 05745 }5846 5947 func findSeedModeIndex(options []seedModeOption, key string) int {@@ -120,22 +120,22 @@ func renderCategoryDetailContent(entry registry.Entry, width int) string {
120120 Width(width).121121 Render(entry.Definition.Description)122122 123- meta := ui.DimItemStyle().Render(fmt.Sprintf("%d modes available", len(entry.Modes)))123+ meta := ui.DimItemStyle().Render(fmt.Sprintf("%d variants available", len(entry.Variants)))124124 content := lipgloss.JoinVertical(lipgloss.Left,125125 ui.PanelTitle().Render(entry.Definition.Name),126126 meta,127127 "",128128 desc,129129 "",130- ui.SelectedItemStyle().Render("Modes"),130+ ui.SelectedItemStyle().Render("Variants"),131131 renderModeList(entry, width),132132 )133133 return content134134 }135135 136136 func renderModeList(entry registry.Entry, width int) string {137- if len(entry.Modes) == 0 {138- return ui.DimItemStyle().Render("No modes available.")137+ if len(entry.Variants) == 0 {138+ return ui.DimItemStyle().Render("No variants available.")139139 }140140 141141 displayTitles := modeDisplayTitles(entry)@@ -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 {@@ -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 {@@ -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 {@@ -53,8 +53,8 @@ func TestRunNewExportValidation(t *testing.T) {
5353 if err := json.Unmarshal([]byte(line), &record); err != nil {5454 t.Fatalf("line %d is not valid jsonl: %v", i+1, err)5555 }56- if record.Schema != pdfexport.ExportSchemaV1 {57- t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV1)56+ if record.Schema != pdfexport.ExportSchemaV2 {57+ t.Fatalf("line %d schema = %q, want %q", i+1, record.Schema, pdfexport.ExportSchemaV2)5858 }5959 }6060 })@@ -151,7 +151,7 @@ func TestRunNewExportOverwritesOutputFile(t *testing.T) {
151151 if string(data) == "old" {152152 t.Fatal("expected output file to be overwritten")153153 }154- if !strings.Contains(string(data), pdfexport.ExportSchemaV1) {154+ if !strings.Contains(string(data), pdfexport.ExportSchemaV2) {155155 t.Fatal("expected jsonl export schema marker")156156 }157157 }@@ -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)@@ -82,7 +82,7 @@ func loadTestRecords(path string) ([]testInputRecord, error) {
8282 if err := json.Unmarshal([]byte(line), &record); err != nil {8383 return nil, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)8484 }85- if record.Schema != pdfexport.ExportSchemaV1 {85+ if !pdfexport.IsSupportedExportSchema(record.Schema) {8686 return nil, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)8787 }8888 @@ -154,7 +154,7 @@ func TestEligibleModesHaveMetadata(t *testing.T) {
154154 }155155 }156156 157-func TestEligibleModesIncludeSudokuRGBDailyTiers(t *testing.T) {157+func TestEligibleModesIncludeSudokuRGBVariant(t *testing.T) {158158 found := map[string]bool{}159159 for _, entry := range eligibleModes {160160 if entry.GameType == "Sudoku RGB" {@@ -162,9 +162,7 @@ func TestEligibleModesIncludeSudokuRGBDailyTiers(t *testing.T) {
162162 }163163 }164164 165- for _, mode := range []string{"Easy", "Medium"} {166- if !found[mode] {167- t.Fatalf("eligibleModes missing Sudoku RGB %s", mode)168- }165+ if !found["Sudoku RGB"] {166+ t.Fatalf("eligibleModes missing Sudoku RGB variant")169167 }170168 }@@ -39,6 +39,9 @@ func TestRunDeterministicWithFixedSpecAndSeed(t *testing.T) {
3939 if string(jsonlA) != string(jsonlB) {4040 t.Fatal("expected identical jsonl output for identical spec and seed")4141 }42+ if !strings.Contains(string(jsonlA), pdfexport.ExportSchemaV2) {43+ t.Fatalf("jsonl schema marker missing %q", pdfexport.ExportSchemaV2)44+ }4245 4346 info, err := os.Stat(specA.PDFOutputPath)4447 if err != nil {@@ -49,6 +52,13 @@ func TestRunDeterministicWithFixedSpecAndSeed(t *testing.T) {
4952 }5053 }5154 55+func TestDefaultSpecUsesPackSchemaV2(t *testing.T) {56+ spec := DefaultSpec(t.TempDir())57+ if spec.Schema != PackSpecSchemaV2 {58+ t.Fatalf("Schema = %q, want %q", spec.Schema, PackSpecSchemaV2)59+ }60+}61+5262 func TestRunSkipsZeroCountModes(t *testing.T) {5363 restore := snapshotNowFn(t, time.Date(2026, 3, 22, 18, 0, 0, 0, time.UTC))5464 defer restore()@@ -106,7 +116,16 @@ func TestValidateSpecRejectsUnknownTargets(t *testing.T) {
106116 spec.Counts[puzzle.CanonicalGameID("Sudoku")][puzzle.ModeID("missing-mode")] = 1107117 return spec108118 }(),109- want: "not a known export mode",119+ want: "not a known export variant",120+ },121+ {122+ name: "unsupported schema",123+ spec: func() Spec {124+ spec := smallTestSpec(dir, "unsupported-schema")125+ spec.Schema = "puzzletea.pack.v1"126+ return spec127+ }(),128+ want: "unsupported pack spec schema",110129 },111130 }112131 @@ -102,8 +102,8 @@ func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[s
102102 lookup := make(map[string]map[string]float64, len(definitions))103103 for _, def := range definitions {104104 titles := []string{}105- for _, mode := range def.Modes {106- title := strings.TrimSpace(mode.Title)105+ for _, variant := range def.Variants {106+ title := strings.TrimSpace(variant.Title)107107 if title == "" {108108 continue109109 }@@ -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@@ -13,7 +13,10 @@ import (
1313 "github.com/FelineStateMachine/puzzletea/difficulty"1414 )1515 16-const ExportSchemaV1 = "puzzletea.export.v1"16+const (17+ ExportSchemaV1 = "puzzletea.export.v1"18+ ExportSchemaV2 = "puzzletea.export.v2"19+)1720 1821 type JSONLRecord struct {1922 Schema string `json:"schema"`@@ -108,7 +111,7 @@ func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error
108111 if err := json.Unmarshal([]byte(line), &record); err != nil {109112 return PackDocument{}, fmt.Errorf("%s:%d: decode jsonl record: %w", path, lineNo, err)110113 }111- if record.Schema != ExportSchemaV1 {114+ if !IsSupportedExportSchema(record.Schema) {112115 return PackDocument{}, fmt.Errorf("%s:%d: unsupported schema %q", path, lineNo, record.Schema)113116 }114117 @@ -187,6 +190,15 @@ func parseJSONLScanner(path string, scanner *bufio.Scanner) (PackDocument, error
187190 return doc, nil188191 }189192 193+func IsSupportedExportSchema(schema string) bool {194+ switch schema {195+ case ExportSchemaV1, ExportSchemaV2:196+ return true197+ default:198+ return false199+ }200+}201+190202 func annotateJSONLDifficulty(p *Puzzle, puzzle JSONLPuzzle) {191203 if puzzle.ActualDifficultyElo != nil {192204 elo := difficulty.Elo(*puzzle.ActualDifficultyElo)@@ -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 }@@ -835,9 +835,16 @@ func componentSizeEntropy(sizes []int) float64 {
835835 for _, size := range sizes {836836 freq[size]++837837 }838+ keys := make([]int, 0, len(freq))839+ for size := range freq {840+ keys = append(keys, size)841+ }842+ sort.Ints(keys)843+838844 denom := float64(len(sizes))839845 entropy := 0.0840- for _, count := range freq {846+ for _, size := range keys {847+ count := freq[size]841848 p := float64(count) / denom842849 entropy -= p * math.Log(p)843850 }@@ -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+}@@ -27,8 +27,9 @@ import (
2727 )2828 2929 type (30- ModeEntry = gameentry.ModeEntry31- Entry = gameentry.Entry30+ ModeEntry = gameentry.ModeEntry31+ VariantEntry = gameentry.VariantEntry32+ Entry = gameentry.Entry3233 )3334 3435 type DailyEntry struct {@@ -124,23 +125,25 @@ func Import(gameType string, data []byte) (game.Gamer, error) {
124125 func DailyEntries() []DailyEntry {125126 dailies := index.DailyEntries()126127 result := make([]DailyEntry, 0, len(dailies))128+ seen := make(map[puzzle.GameID]struct{}, len(dailies))127129 for _, daily := range dailies {130+ if _, ok := seen[daily.GameID]; ok {131+ continue132+ }128133 entry, ok := entriesByID[daily.GameID]129134 if !ok {130135 continue131136 }132- for _, mode := range entry.Modes {133- if mode.Definition.ID != daily.ModeID || mode.Seeded == nil {134- continue135- }137+ if len(entry.Variants) > 0 && entry.Variants[0].Seeded != nil {138+ variant := entry.Variants[0]139+ seen[daily.GameID] = struct{}{}136140 result = append(result, DailyEntry{137- Spawner: mode.Seeded,141+ Spawner: variant.Seeded,138142 GameID: daily.GameID,139143 GameType: entry.Definition.Name,140- ModeID: daily.ModeID,141- Mode: mode.Definition.Title,144+ ModeID: puzzle.ModeID(variant.Definition.ID),145+ Mode: variant.Definition.Title,142146 })143- break144147 }145148 }146149 return result@@ -63,6 +63,31 @@ func TestEntriesStayAlignedWithDefinitions(t *testing.T) {
6363 if got, want := len(entry.Modes), len(def.Modes); got != want {6464 t.Fatalf("%s mode count = %d, want %d", def.Name, got, want)6565 }66+ if got, want := len(entry.Variants), len(def.Variants); got != want {67+ t.Fatalf("%s variant count = %d, want %d", def.Name, got, want)68+ }69+ }70+}71+72+func TestEntriesExposeVariantsAndValidLegacyAliases(t *testing.T) {73+ for _, entry := range Entries() {74+ if len(entry.Variants) == 0 {75+ t.Fatalf("%s has no variants", entry.Definition.Name)76+ }77+78+ variantIDs := make(map[string]struct{}, len(entry.Variants))79+ for _, variant := range entry.Variants {80+ if variant.Elo == nil {81+ t.Fatalf("%s/%s missing Elo spawner", entry.Definition.Name, variant.Definition.Title)82+ }83+ variantIDs[string(variant.Definition.ID)] = struct{}{}84+ }85+86+ for _, alias := range entry.LegacyModes {87+ if _, ok := variantIDs[string(alias.TargetVariantID)]; !ok {88+ t.Fatalf("%s legacy alias %s targets missing variant %s", entry.Definition.Name, alias.Title, alias.TargetVariantID)89+ }90+ }6691 }6792 }6893 @@ -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 }@@ -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 })@@ -117,6 +117,8 @@ func CreateRecordWithDifficulty(
117117 Name: name,118118 GameID: string(puzzle.CanonicalGameID(gameType)),119119 GameType: gameType,120+ VariantID: string(puzzle.CanonicalVariantID(modeTitle)),121+ Variant: modeTitle,120122 ModeID: string(puzzle.CanonicalModeID(modeTitle)),121123 Mode: modeTitle,122124 InitialState: string(initialState),@@ -21,11 +21,18 @@ type Weights map[ModeKey]int
2121 func WeightsFromDefinitions(definitions []puzzle.Definition) Weights {2222 weights := make(Weights, 64)2323 for _, def := range definitions {24- count := len(def.Modes)25- for i, mode := range def.Modes {26- xp := int(math.Round(float64(i) / float64(count) * 10))27- xp = max(xp, 1)28- weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = xp24+ count := len(def.Variants)25+ if count > 0 {26+ for i, variant := range def.Variants {27+ xp := int(math.Round(float64(i) / float64(count) * 10))28+ xp = max(xp, 1)29+ weights[ModeKey{GameType: def.Name, Mode: variant.Title}] = xp30+ }31+ }32+ for _, mode := range def.Modes {33+ if _, ok := weights[ModeKey{GameType: def.Name, Mode: mode.Title}]; !ok {34+ weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = 135+ }2936 }3037 }3138 return weights@@ -33,6 +33,8 @@ type GameRecord struct {
3333 Name string3434 GameID string3535 GameType string36+ VariantID string37+ Variant string3638 ModeID string3739 Mode string3840 InitialState string@@ -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 {@@ -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),@@ -171,6 +171,12 @@ VALUES (?, ?, ?, ?, ?, ?)`,
171171 if got, want := rec.ModeID, "easy"; got != want {172172 t.Fatalf("ModeID = %q, want %q", got, want)173173 }174+ if got, want := rec.VariantID, "easy"; got != want {175+ t.Fatalf("VariantID = %q, want %q", got, want)176+ }177+ if got, want := rec.Variant, "Easy"; got != want {178+ t.Fatalf("Variant = %q, want %q", got, want)179+ }174180 if got, want := rec.RunKind, RunKindDaily; got != want {175181 t.Fatalf("RunKind = %q, want %q", got, want)176182 }@@ -247,6 +253,12 @@ func TestCreateGame(t *testing.T) {
247253 if got.Mode != "Hard 10x10" {248254 t.Errorf("Mode = %q, want %q", got.Mode, "Hard 10x10")249255 }256+ if got.Variant != "Hard 10x10" {257+ t.Errorf("Variant = %q, want %q", got.Variant, "Hard 10x10")258+ }259+ if got.VariantID != "hard 10x10" {260+ t.Errorf("VariantID = %q, want %q", got.VariantID, "hard 10x10")261+ }250262 if got.InitialState != `{"init":true}` {251263 t.Errorf("InitialState = %q, want %q", got.InitialState, `{"init":true}`)252264 }@@ -95,7 +95,7 @@ func TestBonusXP(t *testing.T) {
9595 }9696 }9797 98-func TestEligibleModesIncludeSudokuRGBDailyTiers(t *testing.T) {98+func TestEligibleModesIncludeSudokuRGBVariant(t *testing.T) {9999 found := map[string]bool{}100100 for _, entry := range eligibleModes {101101 if entry.GameType == "Sudoku RGB" {@@ -103,9 +103,7 @@ func TestEligibleModesIncludeSudokuRGBDailyTiers(t *testing.T) {
103103 }104104 }105105 106- for _, mode := range []string{"Easy", "Medium"} {107- if !found[mode] {108- t.Fatalf("eligibleModes missing Sudoku RGB %s", mode)109- }106+ if !found["Sudoku RGB"] {107+ t.Fatalf("eligibleModes missing Sudoku RGB variant")110108 }111109 }