- commit
- 80c5dea39107b753b2f8fdb76c09c6a5e639e0ec
- parent
- 8f1fb6606286a192e6927252b2739a71e88a1b8d
- author
- Dami <dami.etoile@gmail.com>
| M | catalog/ | 3 | + |
| M | export/ | 60 | +++++++++++++++++---- |
| A | export/ | 58 | ++++++++++++++++++++ |
| M | gameentry/ | 33 | +++++++++++- |
| M | gameentry/ | 86 | ++++++++++++++++++++++++++++++ |
| M | puzzle/ | 7 | ++ |
| M | stats/ | 20 | +++++++- |
| M | stats/ | 35 | ++++++++++++ |
@@ -119,6 +119,9 @@ func Validate(definitions []puzzle.Definition) error {
119119 if err := difficulty.ValidateElo(alias.PresetElo); err != nil {120120 return fmt.Errorf("game %q legacy mode %q preset Elo is invalid: %w", def.Name, alias.Title, err)121121 }122+ if alias.XPWeight < 1 {123+ return fmt.Errorf("game %q legacy mode %q XP weight must be positive", def.Name, alias.Title)124+ }122125 }123126 124127 for _, dailyID := range def.DailyModeIDs {@@ -101,27 +101,65 @@ func parseSheetLayout(raw string) (pdfexport.SheetLayout, error) {
101101 func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 {102102 lookup := make(map[string]map[string]float64, len(definitions))103103 for _, def := range definitions {104- titles := []string{}104+ scores := make(map[string]float64)105+ variantTitles := []string{}105106 for _, variant := range def.Variants {106107 title := strings.TrimSpace(variant.Title)107108 if title == "" {108109 continue109110 }110- titles = append(titles, title)111+ variantTitles = append(variantTitles, title)111112 }112- if len(titles) == 0 {113+ addModeOrderScores(scores, variantTitles)114+ addLegacyModeOrderScores(scores, def)115+ if len(scores) == 0 {113116 continue114117 }118+ lookup[normalizeDifficultyToken(def.Name)] = scores119+ }120+ return lookup121+}115122 116- scores := make(map[string]float64, len(titles))117- if len(titles) == 1 {118- scores[normalizeDifficultyToken(titles[0])] = 0.5119- } else {120- for i, title := range titles {121- scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1)123+func addLegacyModeOrderScores(scores map[string]float64, def puzzle.Definition) {124+ if len(def.LegacyModes) == 0 {125+ titles := make([]string, 0, len(def.Modes))126+ for _, mode := range def.Modes {127+ title := strings.TrimSpace(mode.Title)128+ if title != "" {129+ titles = append(titles, title)122130 }123131 }124- lookup[normalizeDifficultyToken(def.Name)] = scores132+ addModeOrderScores(scores, titles)133+ return134+ }135+136+ modeIndex := make(map[puzzle.ModeID]int, len(def.Modes))137+ for i, mode := range def.Modes {138+ modeIndex[mode.ID] = i139+ }140+ for _, alias := range def.LegacyModes {141+ title := strings.TrimSpace(alias.Title)142+ if title == "" {143+ continue144+ }145+ i, ok := modeIndex[alias.ID]146+ if !ok || len(def.Modes) <= 1 {147+ scores[normalizeDifficultyToken(title)] = 0.5148+ continue149+ }150+ scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(def.Modes)-1)151+ }152+}153+154+func addModeOrderScores(scores map[string]float64, titles []string) {155+ switch len(titles) {156+ case 0:157+ return158+ case 1:159+ scores[normalizeDifficultyToken(titles[0])] = 0.5160+ default:161+ for i, title := range titles {162+ scores[normalizeDifficultyToken(title)] = float64(i) / float64(len(titles)-1)163+ }125164 }126- return lookup127165 }@@ -0,0 +1,58 @@
1+package packexport2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/export/pdf"7+ "github.com/FelineStateMachine/puzzletea/puzzle"8+)9+10+func TestAnnotatePuzzlesForPrintUsesLegacyModeOrder(t *testing.T) {11+ definitions := []puzzle.Definition{{12+ Name: "Sudoku",13+ Variants: []puzzle.VariantDef{14+ puzzle.NewVariantDef(puzzle.VariantSpec{Title: "Sudoku", DefaultElo: 1200}),15+ },16+ LegacyModes: []puzzle.LegacyModeAlias{17+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{18+ Title: "Easy",19+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),20+ PresetElo: 600,21+ XPWeight: 1,22+ }),23+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{24+ Title: "Medium",25+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),26+ PresetElo: 1200,27+ XPWeight: 3,28+ }),29+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{30+ Title: "Hard",31+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),32+ PresetElo: 1800,33+ XPWeight: 7,34+ }),35+ },36+ Modes: []puzzle.ModeDef{37+ {ID: puzzle.CanonicalModeID("Easy"), Title: "Easy"},38+ {ID: puzzle.CanonicalModeID("Medium"), Title: "Medium"},39+ {ID: puzzle.CanonicalModeID("Hard"), Title: "Hard"},40+ },41+ }}42+ puzzles := []pdfexport.Puzzle{{43+ Category: "Sudoku",44+ ModeSelection: "Hard",45+ }}46+47+ AnnotatePuzzlesForPrint(puzzles, definitions)48+49+ if got, want := puzzles[0].DifficultyScore, 1.0; got != want {50+ t.Fatalf("DifficultyScore = %v, want %v", got, want)51+ }52+ if got, want := puzzles[0].DifficultyConfidence, pdfexport.DifficultyConfidenceHigh; got != want {53+ t.Fatalf("DifficultyConfidence = %q, want %q", got, want)54+ }55+ if got, want := puzzles[0].DifficultySource, "mode-order"; got != want {56+ t.Fatalf("DifficultySource = %q, want %q", got, want)57+ }58+}@@ -3,7 +3,9 @@
33 package gameentry44 55 import (6+ "context"67 "fmt"8+ "math"79 "math/rand/v2"810 911 "github.com/FelineStateMachine/puzzletea/difficulty"@@ -68,7 +70,7 @@ func BuildDefaultVariantDef(title, description string, defaultElo difficulty.Elo
6870 6971 func BuildLegacyModeAliases(modes []puzzle.ModeDef, target puzzle.VariantID) []puzzle.LegacyModeAlias {7072 aliases := make([]puzzle.LegacyModeAlias, 0, len(modes))71- for _, mode := range modes {73+ for i, mode := range modes {7274 if mode.PresetElo == nil {7375 continue7476 }@@ -78,11 +80,19 @@ func BuildLegacyModeAliases(modes []puzzle.ModeDef, target puzzle.VariantID) []p
7880 Description: mode.Description,7981 TargetVariantID: target,8082 PresetElo: *mode.PresetElo,83+ XPWeight: legacyXPWeight(i, len(modes)),8184 }))8285 }8386 return aliases8487 }8588 89+func legacyXPWeight(index, count int) int {90+ if count <= 0 {91+ return 192+ }93+ return max(1, int(math.Round(float64(index)/float64(count)*10)))94+}95+8696 func NewEntry(spec EntrySpec) Entry {8797 definition := spec.Definition8898 if len(definition.Variants) == 0 && hasEloMode(definition.Modes) {@@ -237,10 +247,17 @@ type variantEloSpawner struct {
237247 }238248 239249 func (s variantEloSpawner) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {250+ return s.SpawnEloContext(context.Background(), seed, elo)251+}252+253+func (s variantEloSpawner) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {240254 if err := difficulty.ValidateElo(elo); err != nil {241255 return nil, difficulty.Report{}, err242256 }243257 mode := s.modeForElo(elo)258+ if cancellable, ok := mode.Elo.(game.CancellableEloSpawner); ok {259+ return cancellable.SpawnEloContext(ctx, seed, elo)260+ }244261 return mode.Elo.SpawnElo(seed, elo)245262 }246263 @@ -268,10 +285,22 @@ func (s variantSeededSpawner) Spawn() (game.Gamer, error) {
268285 }269286 270287 func (s variantSeededSpawner) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {288+ return s.SpawnSeededContext(context.Background(), rng)289+}290+291+func (s variantSeededSpawner) SpawnSeededContext(ctx context.Context, rng *rand.Rand) (game.Gamer, error) {271292 if rng == nil {272293 return nil, fmt.Errorf("nil RNG")273294 }274295 seed := fmt.Sprintf("variant-seeded:%016x:%016x", rng.Uint64(), rng.Uint64())275- g, _, err := s.elo.SpawnElo(seed, s.defaultElo)296+ var (297+ g game.Gamer298+ err error299+ )300+ if cancellable, ok := s.elo.(game.CancellableEloSpawner); ok {301+ g, _, err = cancellable.SpawnEloContext(ctx, seed, s.defaultElo)302+ } else {303+ g, _, err = s.elo.SpawnElo(seed, s.defaultElo)304+ }276305 return g, err277306 }@@ -1,10 +1,12 @@
11 package gameentry22 33 import (4+ "context"45 "errors"56 "math/rand/v2"67 "testing"78 9+ "github.com/FelineStateMachine/puzzletea/difficulty"810 "github.com/FelineStateMachine/puzzletea/game"911 "github.com/FelineStateMachine/puzzletea/puzzle"1012 )@@ -25,6 +27,23 @@ func (m seededStubMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {
2527 return nil, errors.New("not implemented")2628 }2729 30+type cancellableEloStubMode struct {31+ stubMode32+ calledWithCanceledContext bool33+}34+35+func (m *cancellableEloStubMode) SpawnElo(seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {36+ return nil, difficulty.Report{}, errors.New("non-context spawn called")37+}38+39+func (m *cancellableEloStubMode) SpawnEloContext(ctx context.Context, seed string, elo difficulty.Elo) (game.Gamer, difficulty.Report, error) {40+ if err := ctx.Err(); err != nil {41+ m.calledWithCanceledContext = true42+ return nil, difficulty.Report{}, err43+ }44+ return nil, difficulty.Report{}, nil45+}46+2847 func TestBuildModeDefsMarksSeededModes(t *testing.T) {2948 modes := []game.Mode{3049 stubMode{BaseMode: game.NewBaseMode("Beginner", "Easy board")},@@ -73,3 +92,70 @@ func TestNewEntryKeepsMetadataAndRuntimeModesAligned(t *testing.T) {
7392 t.Fatal("Print = non-nil, want nil")7493 }7594 }95+96+func TestVariantEloSpawnerPreservesCancellation(t *testing.T) {97+ preset := difficulty.Elo(1200)98+ mode := &cancellableEloStubMode{99+ stubMode: stubMode{BaseMode: game.NewBaseMode("Hard", "Hard board")},100+ }101+ entry := NewEntry(EntrySpec{102+ Definition: puzzle.NewDefinition(puzzle.DefinitionSpec{103+ Name: "Test",104+ Modes: []puzzle.ModeDef{puzzle.NewModeDef(puzzle.ModeSpec{105+ Title: mode.Title(),106+ Description: mode.Description(),107+ PresetElo: &preset,108+ })},109+ }),110+ Modes: []game.Mode{mode},111+ })112+ if len(entry.Variants) != 1 {113+ t.Fatalf("len(Variants) = %d, want 1", len(entry.Variants))114+ }115+ spawner, ok := entry.Variants[0].Elo.(game.CancellableEloSpawner)116+ if !ok {117+ t.Fatal("variant Elo spawner does not implement game.CancellableEloSpawner")118+ }119+120+ ctx, cancel := context.WithCancel(t.Context())121+ cancel()122+ _, _, err := spawner.SpawnEloContext(ctx, "seed", preset)123+ if !errors.Is(err, context.Canceled) {124+ t.Fatalf("SpawnEloContext error = %v, want context.Canceled", err)125+ }126+ if !mode.calledWithCanceledContext {127+ t.Fatal("underlying mode did not receive the canceled context")128+ }129+}130+131+func TestVariantSeededSpawnerPreservesCancellation(t *testing.T) {132+ preset := difficulty.Elo(1200)133+ mode := &cancellableEloStubMode{134+ stubMode: stubMode{BaseMode: game.NewBaseMode("Hard", "Hard board")},135+ }136+ entry := NewEntry(EntrySpec{137+ Definition: puzzle.NewDefinition(puzzle.DefinitionSpec{138+ Name: "Test",139+ Modes: []puzzle.ModeDef{puzzle.NewModeDef(puzzle.ModeSpec{140+ Title: mode.Title(),141+ Description: mode.Description(),142+ PresetElo: &preset,143+ })},144+ }),145+ Modes: []game.Mode{mode},146+ })147+ spawner, ok := entry.Variants[0].Seeded.(game.CancellableSeededSpawner)148+ if !ok {149+ t.Fatal("variant seeded spawner does not implement game.CancellableSeededSpawner")150+ }151+152+ ctx, cancel := context.WithCancel(t.Context())153+ cancel()154+ _, err := spawner.SpawnSeededContext(ctx, rand.New(rand.NewPCG(1, 2)))155+ if !errors.Is(err, context.Canceled) {156+ t.Fatalf("SpawnSeededContext error = %v, want context.Canceled", err)157+ }158+ if !mode.calledWithCanceledContext {159+ t.Fatal("underlying mode did not receive the canceled context")160+ }161+}@@ -40,6 +40,7 @@ type LegacyModeAlias struct {
4040 Description string4141 TargetVariantID VariantID4242 PresetElo difficulty.Elo43+ XPWeight int4344 CLIAliases []string4445 }4546 @@ -49,6 +50,7 @@ type LegacyModeAliasSpec struct {
4950 Description string5051 TargetVariantID VariantID5152 PresetElo difficulty.Elo53+ XPWeight int5254 CLIAliases []string5355 }5456 @@ -114,12 +116,17 @@ func NewLegacyModeAlias(spec LegacyModeAliasSpec) LegacyModeAlias {
114116 if id == "" {115117 id = CanonicalModeID(spec.Title)116118 }119+ xpWeight := spec.XPWeight120+ if xpWeight < 1 {121+ xpWeight = 1122+ }117123 return LegacyModeAlias{118124 ID: id,119125 Title: spec.Title,120126 Description: spec.Description,121127 TargetVariantID: spec.TargetVariantID,122128 PresetElo: spec.PresetElo,129+ XPWeight: xpWeight,123130 CLIAliases: append([]string(nil), spec.CLIAliases...),124131 }125132 }@@ -29,15 +29,33 @@ func WeightsFromDefinitions(definitions []puzzle.Definition) Weights {
2929 weights[ModeKey{GameType: def.Name, Mode: variant.Title}] = xp3030 }3131 }32+ for _, alias := range def.LegacyModes {33+ weights[ModeKey{GameType: def.Name, Mode: alias.Title}] = max(alias.XPWeight, 1)34+ }3235 for _, mode := range def.Modes {3336 if _, ok := weights[ModeKey{GameType: def.Name, Mode: mode.Title}]; !ok {34- weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = 137+ weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = legacyModeXPWeight(mode, def.Modes)3538 }3639 }3740 }3841 return weights3942 }4043 44+func legacyModeXPWeight(mode puzzle.ModeDef, modes []puzzle.ModeDef) int {45+ count := len(modes)46+ if count == 0 {47+ return 148+ }49+ for i, candidate := range modes {50+ if candidate.ID != mode.ID {51+ continue52+ }53+ xp := int(math.Round(float64(i) / float64(count) * 10))54+ return max(xp, 1)55+ }56+ return 157+}58+4159 // LevelFromXP returns the level for the given total XP.4260 // Uses the inverse of XPForLevel: level = floor((xp / 5) ^ (1/1.6))4361 func LevelFromXP(xp int) int {@@ -22,6 +22,41 @@ func TestWeightsFromDefinitions(t *testing.T) {
2222 }2323 }2424 25+func TestWeightsFromDefinitionsPreservesLegacyModeWeights(t *testing.T) {26+ weights := WeightsFromDefinitions([]puzzle.Definition{{27+ Name: "Sudoku",28+ Variants: []puzzle.VariantDef{29+ puzzle.NewVariantDef(puzzle.VariantSpec{Title: "Sudoku", DefaultElo: 1200}),30+ },31+ LegacyModes: []puzzle.LegacyModeAlias{32+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{33+ Title: "Easy",34+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),35+ PresetElo: 600,36+ XPWeight: 1,37+ }),38+ puzzle.NewLegacyModeAlias(puzzle.LegacyModeAliasSpec{39+ Title: "Hard",40+ TargetVariantID: puzzle.CanonicalVariantID("Sudoku"),41+ PresetElo: 1800,42+ XPWeight: 7,43+ }),44+ },45+ Modes: []puzzle.ModeDef{46+ {ID: puzzle.CanonicalModeID("Easy"), Title: "Easy"},47+ {ID: puzzle.CanonicalModeID("Medium"), Title: "Medium"},48+ {ID: puzzle.CanonicalModeID("Hard"), Title: "Hard"},49+ },50+ }})51+52+ if got, want := weights[ModeKey{GameType: "Sudoku", Mode: "Hard"}], 7; got != want {53+ t.Fatalf("legacy Hard weight = %d, want %d", got, want)54+ }55+ if got, want := weights[ModeKey{GameType: "Sudoku", Mode: "Sudoku"}], 1; got != want {56+ t.Fatalf("variant weight = %d, want %d", got, want)57+ }58+}59+2560 func TestLevelFromXPAndXPForLevel(t *testing.T) {2661 if got := LevelFromXP(0); got != 0 {2762 t.Fatalf("LevelFromXP(0) = %d, want 0", got)