clone

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

swap rng to HRW to stabilize generation to be easier to seed. swapped to subRNG for name, and passing direct RNG results to the generation seed. This allows us to generate stable games with set rng and reproduce across versions (idealisitically)

Dami committed c626d15
commit
c626d154ab134d245953916ddc52e3d969588e18
parent
f9c13f19a4d05ed1532986b36d4cd682436f2f7b
author
Dami <dami.etoile@gmail.com>
8 files changed, +412 -28
Mapp/update.go8+-
Mcmd/new.go106++++++++++++++++++++++-
Mcmd/root.go9++
Mdaily/daily.go44+++++++--
Mdaily/daily_test.go68++++++++++++--
Mresolve/resolve.go62+++++++++++++
Mresolve/resolve_test.go140++++++++++++++++++++++++++++++
Mstats/stats.go3+-
M app/update.go +5 -3
M cmd/new.go +104 -2
@@ -6,6 +6,8 @@ import (
66 "strings"77 88 "github.com/FelineStateMachine/puzzletea/app"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/namegen"911 "github.com/FelineStateMachine/puzzletea/resolve"1012 "github.com/FelineStateMachine/puzzletea/stats"1113 "github.com/FelineStateMachine/puzzletea/store"
@@ -14,13 +16,25 @@ import (
1416 "github.com/spf13/cobra"1517 )1618 19+var flagSetSeed string20+1721 var newCmd = &cobra.Command{1822 Use: "new <game> [mode]",1923 Short: "Start a new puzzle game",20- Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n\nAvailable games:\n %s",24+ Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n"+25+ "Use --set-seed to generate a deterministic puzzle from a seed string.\n\nAvailable games:\n %s",2126 strings.Join(resolve.CategoryNames(app.GameCategories), "\n ")),22- Args: cobra.RangeArgs(1, 2),27+ Args: cobra.RangeArgs(0, 2),2328 RunE: func(cmd *cobra.Command, args []string) error {29+ if flagSetSeed != "" {30+ if len(args) > 0 {31+ return fmt.Errorf("cannot specify game/mode arguments with --set-seed; the seed determines the puzzle type")32+ }33+ return launchSeededGame(flagSetSeed)34+ }35+ if len(args) == 0 {36+ return fmt.Errorf("requires at least 1 arg(s), only received 0")37+ }2438 gameArg := args[0]2539 var modeArg string2640 if len(args) > 1 {
@@ -30,6 +44,10 @@ var newCmd = &cobra.Command{
3044 },3145 }3246 47+func init() {48+ newCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation")49+}50+3351 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.3452 func launchNewGame(gameArg, modeArg string) error {3553 cat, err := resolve.Category(gameArg, app.GameCategories)
@@ -80,3 +98,87 @@ func launchNewGame(gameArg, modeArg string) error {
8098 _, err = p.Run()8199 return err82100 }101+102+// launchSeededGame uses an arbitrary seed string to deterministically select103+// a game type, mode, and puzzle. The same seed always produces the same puzzle.104+// If a game with the same seed-derived name already exists, it is resumed.105+//106+// Name generation and mode selection each use independent hashing so that107+// changes to namegen word lists or mode lists don't cascade. The shared108+// RNG is reserved purely for puzzle content generation.109+func launchSeededGame(seed string) error {110+ // Name uses its own sub-RNG so namegen changes can't affect mode111+ // selection or puzzle generation.112+ nameRNG := resolve.RNGFromString("name:" + seed)113+ name := seed + " - " + namegen.GenerateSeeded(nameRNG)114+115+ s, err := store.Open(store.DefaultDBPath())116+ if err != nil {117+ return err118+ }119+ defer s.Close()120+121+ stats.InitModeXP(app.GameCategories)122+123+ // If a game with this name already exists, resume it (including124+ // abandoned games — seeded puzzles are deterministic and should125+ // always be resumable rather than duplicated).126+ if rec, err := s.GetDailyGame(name); err == nil && rec != nil {127+ importFn, ok := game.Registry[rec.GameType]128+ if !ok {129+ return fmt.Errorf("unknown game type %q in save data", rec.GameType)130+ }131+ g, err := importFn([]byte(rec.SaveState))132+ if err != nil {133+ return fmt.Errorf("failed to import saved game: %w", err)134+ }135+ g = g.SetTitle(rec.Name)136+ completed := rec.Status == store.StatusCompleted137+138+ // Resume abandoned seeded games by resetting their status.139+ if rec.Status == store.StatusAbandoned {140+ _ = s.UpdateStatus(rec.ID, store.StatusInProgress)141+ }142+143+ m := app.InitialModelWithGame(s, g, rec.ID, completed)144+ p := tea.NewProgram(m)145+ _, err = p.Run()146+ return err147+ }148+149+ // Mode selection uses rendezvous hashing (independent of RNG).150+ spawner, gameType, modeTitle, err := resolve.SeededMode(seed, app.GameCategories)151+ if err != nil {152+ return err153+ }154+155+ // RNG is reserved purely for puzzle content generation.156+ rng := resolve.RNGFromString(seed)157+ g, err := spawner.SpawnSeeded(rng)158+ if err != nil {159+ return fmt.Errorf("failed to spawn game: %w", err)160+ }161+ g = g.SetTitle(name)162+163+ initialState, err := g.GetSave()164+ if err != nil {165+ return fmt.Errorf("failed to get initial save: %w", err)166+ }167+168+ rec := &store.GameRecord{169+ Name: name,170+ GameType: gameType,171+ Mode: modeTitle,172+ InitialState: string(initialState),173+ SaveState: string(initialState),174+ Status: store.StatusNew,175+ }176+ if err := s.CreateGame(rec); err != nil {177+ log.Printf("failed to create game record: %v", err)178+ }179+180+ m := app.InitialModelWithGame(s, g, rec.ID, false)181+ p := tea.NewProgram(m)182+ _, err = p.Run()183+ return err184+}
M cmd/root.go +9 -0
M daily/daily.go +34 -10
@@ -77,16 +77,40 @@ func RNG(date time.Time) *rand.Rand {
7777 // Name generates the daily puzzle name in the format:7878 // "Daily Feb 14 26 - amber-falcon"7979 //80-// NOTE: this must be called before Mode on the same RNG — the number81-// of draws here determines which mode is selected. Changing this function82-// will shift daily puzzles for all future dates.83-func Name(date time.Time, rng *rand.Rand) string {84- return "Daily " + date.Format("Jan _2 06") + " - " + namegen.GenerateSeeded(rng)80+// Name uses its own sub-RNG derived from the date so that changes to81+// the namegen word lists cannot affect mode selection or puzzle generation.82+func Name(date time.Time) string {83+ h := fnv.New64a()84+ h.Write([]byte("name:"))85+ h.Write([]byte(date.Format("2006-01-02")))86+ seed := h.Sum64()87+ nameRNG := rand.New(rand.NewPCG(seed, ^seed))88+ return "Daily " + date.Format("Jan _2 06") + " - " + namegen.GenerateSeeded(nameRNG)8589 }8690 87-// Mode selects the daily mode from the eligible pool using the seeded RNG.88-// Returns the spawner, game type name, and mode title.89-func Mode(rng *rand.Rand) (game.SeededSpawner, string, string) {90- entry := eligibleModes[rng.IntN(len(eligibleModes))]91- return entry.Spawner, entry.GameType, entry.Mode91+// Mode selects the daily mode from the eligible pool using rendezvous92+// hashing (highest random weight). Each entry is scored by hashing the93+// date together with its (GameType, Mode) pair; the highest score wins.94+//95+// This is resilient to changes in the pool: adding or removing an entry96+// only affects dates where the changed entry would have been the winner.97+func Mode(date time.Time) (game.SeededSpawner, string, string) {98+ dateStr := date.Format("2006-01-02")99+100+ var best Entry101+ var bestHash uint64102+ for _, entry := range eligibleModes {103+ h := fnv.New64a()104+ h.Write([]byte(dateStr))105+ h.Write([]byte{0})106+ h.Write([]byte(entry.GameType))107+ h.Write([]byte{0})108+ h.Write([]byte(entry.Mode))109+ score := h.Sum64()110+ if score > bestHash {111+ bestHash = score112+ best = entry113+ }114+ }115+ return best.Spawner, best.GameType, best.Mode92116 }
M daily/daily_test.go +57 -11
@@ -1,7 +1,6 @@
11 package daily22 33 import (4- "math/rand/v2"54 "strings"65 "testing"76 "time"
@@ -89,8 +88,7 @@ func TestRNGDifferentDates(t *testing.T) {
8988 9089 func TestName(t *testing.T) {9190 date := time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC)92- rng := RNG(date)93- name := Name(date, rng)91+ name := Name(date)9492 9593 if !strings.HasPrefix(name, "Daily Feb 14 26 - ") {9694 t.Errorf("Name = %q, want prefix %q", name, "Daily Feb 14 26 - ")
@@ -99,8 +97,8 @@ func TestName(t *testing.T) {
9997 10098 func TestNameDeterministic(t *testing.T) {10199 date := time.Date(2026, 7, 4, 0, 0, 0, 0, time.UTC)102- name1 := Name(date, RNG(date))103- name2 := Name(date, RNG(date))100+ name1 := Name(date)101+ name2 := Name(date)104102 if name1 != name2 {105103 t.Errorf("Name not deterministic: %q != %q", name1, name2)106104 }
@@ -109,8 +107,8 @@ func TestNameDeterministic(t *testing.T) {
109107 // --- Mode (P1) ---110108 111109 func TestMode(t *testing.T) {112- rng := rand.New(rand.NewPCG(42, 99))113- spawner, gameType, mode := Mode(rng)110+ date := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)111+ spawner, gameType, mode := Mode(date)114112 115113 if spawner == nil {116114 t.Fatal("Mode returned nil spawner")
@@ -124,17 +122,65 @@ func TestMode(t *testing.T) {
124122 }125123 126124 func TestModeDeterministic(t *testing.T) {127- rng1 := rand.New(rand.NewPCG(100, 200))128- rng2 := rand.New(rand.NewPCG(100, 200))125+ date := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)129126 130- _, gt1, m1 := Mode(rng1)131- _, gt2, m2 := Mode(rng2)127+ _, gt1, m1 := Mode(date)128+ _, gt2, m2 := Mode(date)132129 133130 if gt1 != gt2 || m1 != m2 {134131 t.Errorf("Mode not deterministic: (%q,%q) vs (%q,%q)", gt1, m1, gt2, m2)135132 }136133 }137134 135+func TestModeStableOnPoolChange(t *testing.T) {136+ // Verify the core property of rendezvous hashing: for a given date,137+ // the selected entry depends only on the (date, gameType, mode) triple,138+ // not on the total number of entries in the pool.139+ //140+ // We test this by recording the mode for a set of dates, then adding141+ // a synthetic entry to eligibleModes and confirming that dates which142+ // did NOT select the new entry still return the same mode as before.143+ dates := make([]time.Time, 30)144+ for i := range dates {145+ dates[i] = time.Date(2026, 1, 1+i, 0, 0, 0, 0, time.UTC)146+ }147+148+ // Record original selections.149+ type selection struct {150+ gameType string151+ mode string152+ }153+ original := make([]selection, len(dates))154+ for i, d := range dates {155+ _, gt, m := Mode(d)156+ original[i] = selection{gt, m}157+ }158+159+ // Temporarily add a synthetic entry.160+ synth := Entry{161+ Spawner: eligibleModes[0].Spawner,162+ GameType: "SyntheticGame",163+ Mode: "SyntheticMode",164+ }165+ eligibleModes = append(eligibleModes, synth)166+ defer func() {167+ eligibleModes = eligibleModes[:len(eligibleModes)-1]168+ }()169+170+ for i, d := range dates {171+ _, gt, m := Mode(d)172+ if gt == "SyntheticGame" && m == "SyntheticMode" {173+ // This date was "stolen" by the new entry — expected for some dates.174+ continue175+ }176+ if gt != original[i].gameType || m != original[i].mode {177+ t.Errorf("date %s: selection changed from (%q,%q) to (%q,%q) after adding unrelated entry",178+ d.Format("2006-01-02"),179+ original[i].gameType, original[i].mode, gt, m)180+ }181+ }182+}183+138184 // --- EligibleModes pool (P1) ---139185 140186 func TestEligibleModesNotEmpty(t *testing.T) {
M resolve/resolve.go +62 -0
@@ -1,7 +1,10 @@
11 package resolve22 33 import (4+ "errors"45 "fmt"6+ "hash/fnv"7+ "math/rand/v2"58 "strings"69 710 "github.com/FelineStateMachine/puzzletea/game"
@@ -98,3 +101,62 @@ func ModeNames(cat game.Category) []string {
98101 }99102 return names100103 }104+105+// RNGFromString creates a deterministic RNG seeded from an arbitrary string.106+// The string is hashed with FNV-64a to produce a uint64 seed.107+func RNGFromString(seed string) *rand.Rand {108+ h := fnv.New64a()109+ h.Write([]byte(seed))110+ s := h.Sum64()111+ return rand.New(rand.NewPCG(s, ^s))112+}113+114+// seededEntry pairs a SeededSpawner with its category and mode metadata.115+type seededEntry struct {116+ spawner game.SeededSpawner117+ gameType string118+ mode string119+}120+121+// SeededMode selects a game type and mode from all available modes across122+// all categories using rendezvous hashing (highest random weight). Each123+// eligible mode is scored by hashing the seed string together with its124+// (gameType, modeTitle) pair; the highest score wins.125+//126+// This is resilient to changes in the category/mode lists: adding or127+// removing a mode only affects seeds where the changed entry would have128+// been the winner.129+func SeededMode(seed string, categories []list.Item) (game.SeededSpawner, string, string, error) {130+ var best seededEntry131+ var bestHash uint64132+ found := false133+ for _, item := range categories {134+ cat := item.(game.Category)135+ for _, modeItem := range cat.Modes {136+ s, ok := modeItem.(game.SeededSpawner)137+ if !ok {138+ continue139+ }140+ h := fnv.New64a()141+ h.Write([]byte(seed))142+ h.Write([]byte{0})143+ h.Write([]byte(cat.Name))144+ h.Write([]byte{0})145+ h.Write([]byte(modeItem.(game.Mode).Title()))146+ score := h.Sum64()147+ if !found || score > bestHash {148+ bestHash = score149+ best = seededEntry{150+ spawner: s,151+ gameType: cat.Name,152+ mode: modeItem.(game.Mode).Title(),153+ }154+ found = true155+ }156+ }157+ }158+ if !found {159+ return nil, "", "", errors.New("no seeded modes available")160+ }161+ return best.spawner, best.gameType, best.mode, nil162+}
M resolve/resolve_test.go +140 -0
@@ -246,3 +246,143 @@ func TestModeNames(t *testing.T) {
246246 }247247 }248248 }249+250+// --- RNGFromString (P0) ---251+252+func TestRNGFromString(t *testing.T) {253+ t.Run("deterministic output", func(t *testing.T) {254+ rng1 := RNGFromString("hello-world")255+ rng2 := RNGFromString("hello-world")256+257+ // Draw several values and confirm they match.258+ for i := range 10 {259+ v1 := rng1.Uint64()260+ v2 := rng2.Uint64()261+ if v1 != v2 {262+ t.Fatalf("draw %d: got %d and %d for same seed", i, v1, v2)263+ }264+ }265+ })266+267+ t.Run("different seeds produce different output", func(t *testing.T) {268+ rng1 := RNGFromString("seed-a")269+ rng2 := RNGFromString("seed-b")270+271+ // Extremely unlikely for two different seeds to produce the same first draw.272+ if rng1.Uint64() == rng2.Uint64() {273+ t.Error("different seeds produced identical first draw")274+ }275+ })276+277+ t.Run("empty string is valid", func(t *testing.T) {278+ rng := RNGFromString("")279+ // Should not panic; just verify it produces output.280+ _ = rng.Uint64()281+ })282+}283+284+// --- SeededMode (P0) ---285+286+func TestSeededMode(t *testing.T) {287+ t.Run("deterministic selection", func(t *testing.T) {288+ s1, gt1, m1, err := SeededMode("test-seed-42", testCategories)289+ if err != nil {290+ t.Fatal(err)291+ }292+ s2, gt2, m2, err := SeededMode("test-seed-42", testCategories)293+ if err != nil {294+ t.Fatal(err)295+ }296+297+ if gt1 != gt2 {298+ t.Errorf("game types differ: %q vs %q", gt1, gt2)299+ }300+ if m1 != m2 {301+ t.Errorf("mode titles differ: %q vs %q", m1, m2)302+ }303+ if s1 == nil || s2 == nil {304+ t.Fatal("expected non-nil spawners")305+ }306+ })307+308+ t.Run("returns valid metadata", func(t *testing.T) {309+ spawner, gameType, modeTitle, err := SeededMode("test-seed", testCategories)310+ if err != nil {311+ t.Fatal(err)312+ }313+ if spawner == nil {314+ t.Fatal("expected non-nil spawner")315+ }316+ if gameType == "" {317+ t.Error("expected non-empty game type")318+ }319+ if modeTitle == "" {320+ t.Error("expected non-empty mode title")321+ }322+ })323+324+ t.Run("empty categories returns error", func(t *testing.T) {325+ _, _, _, err := SeededMode("test", []list.Item{})326+ if err == nil {327+ t.Fatal("expected error for empty categories")328+ }329+ })330+331+ t.Run("categories with no seeded spawners returns error", func(t *testing.T) {332+ // Category with no modes at all.333+ cats := []list.Item{334+ game.Category{Name: "Empty", Modes: []list.Item{}},335+ }336+ _, _, _, err := SeededMode("test", cats)337+ if err == nil {338+ t.Fatal("expected error when no seeded modes available")339+ }340+ })341+342+ t.Run("stable on category change", func(t *testing.T) {343+ // Verify rendezvous hashing stability: adding a category should344+ // only affect seeds where the new category's mode wins.345+ seeds := []string{346+ "alpha", "bravo", "charlie", "delta", "echo",347+ "foxtrot", "golf", "hotel", "india", "juliet",348+ }349+350+ type selection struct {351+ gameType string352+ mode string353+ }354+355+ // Record selections with original categories.356+ original := make([]selection, len(seeds))357+ for i, seed := range seeds {358+ _, gt, m, err := SeededMode(seed, testCategories)359+ if err != nil {360+ t.Fatal(err)361+ }362+ original[i] = selection{gt, m}363+ }364+365+ // Add a synthetic category with one mode.366+ extended := make([]list.Item, len(testCategories))367+ copy(extended, testCategories)368+ extended = append(extended, game.Category{369+ Name: "Synthetic",370+ Desc: "Fake game for testing",371+ Modes: []list.Item{lightsout.Modes[0]},372+ })373+374+ for i, seed := range seeds {375+ _, gt, m, err := SeededMode(seed, extended)376+ if err != nil {377+ t.Fatal(err)378+ }379+ if gt == "Synthetic" {380+ continue // this seed was "stolen" by the new entry381+ }382+ if gt != original[i].gameType || m != original[i].mode {383+ t.Errorf("seed %q: selection changed from (%q,%q) to (%q,%q) after adding unrelated category",384+ seed, original[i].gameType, original[i].mode, gt, m)385+ }386+ }387+ })388+}
M stats/stats.go +1 -2