- commit
- c626d154ab134d245953916ddc52e3d969588e18
- parent
- f9c13f19a4d05ed1532986b36d4cd682436f2f7b
- author
- Dami <dami.etoile@gmail.com>
| M | app/ | 8 | +- |
| M | cmd/ | 106 | ++++++++++++++++++++++- |
| M | cmd/ | 9 | ++ |
| M | daily/ | 44 | +++++++-- |
| M | daily/ | 68 | ++++++++++++-- |
| M | resolve/ | 62 | +++++++++++++ |
| M | resolve/ | 140 | ++++++++++++++++++++++++++++++ |
| M | stats/ | 3 | +- |
@@ -178,8 +178,7 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
178178 179179 func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {180180 today := time.Now()181- rng := daily.RNG(today)182- name := daily.Name(today, rng)181+ name := daily.Name(today)183182 184183 // Check if a daily game already exists for today.185184 rec, err := m.store.GetDailyGame(name)@@ -215,7 +214,10 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
215214 }216215 217216 // No existing daily - generate a new one.218- spawner, gameType, modeTitle := daily.Mode(rng)217+ // Mode selection uses rendezvous hashing (independent of RNG).218+ // RNG is reserved purely for puzzle content generation.219+ spawner, gameType, modeTitle := daily.Mode(today)220+ rng := daily.RNG(today)219221 m.dailyPending = true220222 m.dailyName = name221223 m.dailyGameType = gameType@@ -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+}@@ -2,6 +2,7 @@
22 package cmd33 44 import (5+ "fmt"56 "strings"67 78 "github.com/FelineStateMachine/puzzletea/app"@@ -20,6 +21,7 @@ var Version = "dev"
2021 var (2122 flagNew string2223 flagContinue string24+ // flagSetSeed is declared in new.go and shared across root and new commands.2325 )2426 2527 // RootCmd is the top-level Cobra command.@@ -29,6 +31,12 @@ var RootCmd = &cobra.Command{
2931 Short: "A terminal-based puzzle game framework",3032 Long: "PuzzleTea is a terminal-based puzzle game framework featuring Nonogram, Sudoku, Word Search, Hashiwokakero, and Lights Out.",3133 RunE: func(cmd *cobra.Command, args []string) error {34+ if flagSetSeed != "" {35+ if flagNew != "" || flagContinue != "" {36+ return fmt.Errorf("--set-seed cannot be combined with --new or --continue")37+ }38+ return launchSeededGame(flagSetSeed)39+ }3240 if flagNew != "" {3341 parts := strings.SplitN(flagNew, ":", 2)3442 gameArg := parts[0]@@ -57,6 +65,7 @@ var RootCmd = &cobra.Command{
5765 func init() {5866 RootCmd.Flags().StringVar(&flagNew, "new", "", "start a new game (game:mode)")5967 RootCmd.Flags().StringVar(&flagContinue, "continue", "", "resume a saved game by name")68+ RootCmd.Flags().StringVar(&flagSetSeed, "set-seed", "", "seed string for deterministic puzzle selection and generation")6069 6170 RootCmd.AddCommand(newCmd, continueCmd, listCmd)6271 }@@ -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 }@@ -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) {@@ -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+}@@ -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+}@@ -205,8 +205,7 @@ func BuildProfileBanner(
205205 streak := ComputeDailyStreak(streakDates, now)206206 207207 // Check if today's daily exists in the DB.208- rng := daily.RNG(now)209- todayName := daily.Name(now, rng)208+ todayName := daily.Name(now)210209 rec, _ := s.GetDailyGame(todayName)211210 currentDaily := rec != nil212211