diff --git a/README.md b/README.md index b6c60b5..99bcb20 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,8 @@ the auto-repeat heuristic. | p (index/library) | cycle pixel style (incl. ASCII art) | | q | quit (from menu) | -High scores persist to `~/.config/termcade/scores.json`. +High scores persist to `~/.config/termcade/scores.json`, and are yours whether +or not you have an account — see [Your history](#your-history). ## The marketplace @@ -131,6 +132,28 @@ termcade list # what's here termcade remove author/slug # take one off (updates your library too) ``` +## Your history + +Your high scores live in `scores.json` and always have. The arcade plays with +no account and no network, and a score set that way is not waiting on either. + +What an account adds is that the same history follows you. Every finished run +is recorded locally and queued; the queue drains when there is a session and a +network, which may be now, may be after you sign in, and may be next week. On +another machine, signing in brings the account's side down. Both directions +only ever raise a value — a high set here and a higher one set there both +survive — so the two can disagree, sync in either order, or never sync, and +the worst outcome is that a score stays where it was set. + +The library also marks a game the marketplace has moved past, comparing the +version in the installed package's own manifest with what the catalog +publishes. `termcade add` again is what updates it. + +**These are personal records, not a leaderboard.** A score comes from a game +running in your own sandbox, and nothing on the other end can check it. It is +your history, shown back to you; ranking anybody by it would need evidence +that does not exist yet, so nothing here pretends to. + There is no way to install an older version, and that is deliberate. A game is not a dependency — nothing builds against one — so the reasons package managers pin (reproducible builds, lockfiles, a transitive bump breaking you) diff --git a/activity.go b/activity.go new file mode 100644 index 0000000..43bc670 --- /dev/null +++ b/activity.go @@ -0,0 +1,118 @@ +package main + +import ( + "errors" + "strconv" + "strings" + "time" + + "github.com/aviorstudio/termcade/internal/registry" + "github.com/aviorstudio/termcade/internal/scores" +) + +// Keeping one account's arcade in step across machines. +// +// Playing never waits on any of this. A run is recorded locally and queued; +// the queue drains later, from wherever the arcade happens to be when it next +// has a session and a network. Nothing here can fail in a way that costs a +// score: the local file is authoritative for playing, and the registry is +// authoritative only for what other machines did. +// +// Both directions merge upward. Sending a run cannot lower what the account +// holds, because the registry merges with max(); receiving cannot lower what +// this machine holds, because scores.Merge does the same. So the two can +// disagree, sync in either order, or never sync, and the worst outcome is that +// a high score stays where it was set. + +// syncActivity flushes queued runs and folds the account's record into the +// local one. It reports how many runs it delivered, so a caller can say +// something only when there was something to say. +// +// Signed out is not a failure — it is the ordinary state of an arcade nobody +// has made an account for, and the queue simply waits. +func syncActivity(st *scores.Store) (sent int, err error) { + session, err := registry.LoadSession() + if err != nil || session == nil { + return 0, nil + } + client := registry.New(registry.URL(session), session.Token) + + sent, err = flushRuns(client, st) + if err != nil { + // A run that did not land stays queued. Pulling the account's record + // anyway would be reporting on a sync that half happened, so this stops + // here and the next attempt does both. + return sent, err + } + return sent, pullActivity(client, st) +} + +// flushRuns delivers the queue oldest first, dropping each run only once the +// registry has acknowledged it. +// +// A rejection the registry will keep repeating — a malformed run, a game no +// longer in the catalog — drops the run rather than blocking everything behind +// it forever. Anything else stops the flush and leaves the rest queued. +func flushRuns(client *registry.Client, st *scores.Store) (int, error) { + sent := 0 + for _, run := range st.Pending() { + author, slug, ok := strings.Cut(run.Game, "/") + if !ok { + st.Sent(run.ID) // not an id the registry could ever accept + continue + } + _, err := client.SubmitRun(author, slug, registry.Run{ + Run: run.ID, + Score: run.Score, + PlayedAt: run.PlayedAt.UTC().Format(time.RFC3339), + Completed: run.Completed, + Version: run.Version, + }) + switch { + case err == nil: + st.Sent(run.ID) + sent++ + case errors.Is(err, registry.ErrNotFound): + // The game is gone from the catalog. Its local high score stays; + // there is simply nowhere to record the run. + st.Sent(run.ID) + default: + return sent, err + } + } + return sent, nil +} + +// pullActivity folds the account's record into the local file. Only upward: +// see the package comment. +func pullActivity(client *registry.Client, st *scores.Store) error { + records, err := client.Activity() + if err != nil { + return err + } + for _, a := range records { + st.Merge(a.ID, a.PersonalBest, a.PlayedAt()) + } + return nil +} + +// syncMessage turns a sync into something worth putting on a screen, or "" for +// the ordinary case where nothing happened and nobody needs telling. +// +// An expired session is the one failure a player can act on, so it is named. +// Everything else is the marketplace being unreachable, which the arcade +// already survives by design — the queue is still there and the next start +// tries again. +func syncMessage(sent int, err error) string { + switch { + case errors.Is(err, registry.ErrLoginRequired): + return "your session has expired — press l in the marketplace to sign in again" + case err != nil: + return "" + case sent == 1: + return "1 run synced to your account" + case sent > 1: + return strconv.Itoa(sent) + " runs synced to your account" + } + return "" +} diff --git a/activity_test.go b/activity_test.go new file mode 100644 index 0000000..1482310 --- /dev/null +++ b/activity_test.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/aviorstudio/termcade/internal/registry" + "github.com/aviorstudio/termcade/internal/scores" +) + +// The sync is the only place where an arcade that works offline meets an +// account that might not be reachable. Every test here is a way that can go +// wrong without anybody noticing: a run delivered twice, a run silently +// dropped, a local high quietly replaced by a worse one from the account. + +// fakeRegistry answers /v1/activity and records what was submitted. +type fakeRegistry struct { + *httptest.Server + submitted []registry.Run + paths []string + // records is what GET /v1/activity returns. + records []registry.Activity + // fail, when set, is the status every submission gets. + fail int +} + +func newFakeRegistry(t *testing.T) *fakeRegistry { + t.Helper() + f := &fakeRegistry{} + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.paths = append(f.paths, r.Method+" "+r.URL.Path) + switch { + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/activity/"): + if f.fail != 0 { + w.WriteHeader(f.fail) + json.NewEncoder(w).Encode(map[string]string{"message": "no"}) + return + } + var run registry.Run + json.NewDecoder(r.Body).Decode(&run) + f.submitted = append(f.submitted, run) + json.NewEncoder(w).Encode(registry.Activity{ID: strings.TrimPrefix(r.URL.Path, "/v1/activity/")}) + case r.URL.Path == "/v1/activity": + json.NewEncoder(w).Encode(f.records) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(f.Close) + return f +} + +// session points the arcade at the fake registry with a token, in a config +// directory of its own. +func session(t *testing.T, f *fakeRegistry) *scores.Store { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("TERMCADE_REGISTRY", f.URL) + if err := registry.SaveSession(registry.Session{ + Registry: f.URL, Email: "p@t.dev", Token: "good", + }); err != nil { + t.Fatalf("saving a session: %v", err) + } + st, err := scores.Load() + if err != nil { + t.Fatalf("loading scores: %v", err) + } + return st +} + +func TestSyncSendsTheQueueAndClearsIt(t *testing.T) { + f := newFakeRegistry(t) + st := session(t, f) + st.Record("aviorstudio/tetris", 900, "1.2.0", true) + st.Record("aviorstudio/asteroid", 10, "", false) + + sent, err := syncActivity(st) + if err != nil { + t.Fatalf("sync: %v", err) + } + if sent != 2 { + t.Errorf("sent %d runs, want 2", sent) + } + if got := st.Pending(); len(got) != 0 { + t.Errorf("queue not cleared: %v", got) + } + if len(f.submitted) != 2 { + t.Fatalf("registry saw %d runs", len(f.submitted)) + } + if f.submitted[0].Score != 900 || f.submitted[0].Version != "1.2.0" { + t.Errorf("first run went out wrong: %+v", f.submitted[0]) + } + if !f.submitted[0].Completed || f.submitted[1].Completed { + t.Errorf("completion not carried: %+v %+v", f.submitted[0], f.submitted[1]) + } + // The timestamp is the run's, not the sync's — an offline queue that + // reported "now" on delivery would rewrite when everything happened. + if _, err := time.Parse(time.RFC3339, f.submitted[0].PlayedAt); err != nil { + t.Errorf("played_at %q is not RFC 3339: %v", f.submitted[0].PlayedAt, err) + } +} + +// A run stays queued until it is acknowledged, and the retry carries the same +// id — which is the entire reason retrying is safe. +func TestAFailedRunStaysQueuedUnderTheSameID(t *testing.T) { + f := newFakeRegistry(t) + f.fail = http.StatusInternalServerError + st := session(t, f) + st.Record("aviorstudio/tetris", 900, "1.2.0", true) + queued := st.Pending()[0].ID + + if sent, err := syncActivity(st); err == nil || sent != 0 { + t.Fatalf("a failing sync reported sent=%d err=%v", sent, err) + } + pending := st.Pending() + if len(pending) != 1 || pending[0].ID != queued { + t.Fatalf("run did not stay queued under its id: %v", pending) + } + + f.fail = 0 + if sent, err := syncActivity(st); err != nil || sent != 1 { + t.Fatalf("retry: sent=%d err=%v", sent, err) + } + if f.submitted[0].Run != queued { + t.Errorf("retry used a new run id (%s, was %s) — the registry would count two plays", + f.submitted[0].Run, queued) + } +} + +// A run for a game the catalog no longer has can never be delivered. Leaving +// it queued would block everything behind it forever. +func TestARunTheRegistryWillNeverAcceptIsDropped(t *testing.T) { + f := newFakeRegistry(t) + f.fail = http.StatusNotFound + st := session(t, f) + st.Record("aviorstudio/gone", 900, "", true) + + if _, err := syncActivity(st); err != nil { + t.Fatalf("sync: %v", err) + } + if got := st.Pending(); len(got) != 0 { + t.Errorf("undeliverable run still queued: %v", got) + } + // Its score was already local and stays that way. + if got := st.High("aviorstudio/gone"); got != 900 { + t.Errorf("high = %d, want 900", got) + } +} + +func TestSyncMergesTheAccountsRecordWithoutLoweringAnything(t *testing.T) { + f := newFakeRegistry(t) + f.records = []registry.Activity{ + // Played higher somewhere else. + {ID: "aviorstudio/asteroid", PersonalBest: 5000, + LastPlayed: time.Now().UTC().Format(time.RFC3339)}, + // The account is behind this machine. + {ID: "aviorstudio/tetris", PersonalBest: 10, + LastPlayed: time.Now().UTC().Add(-72 * time.Hour).Format(time.RFC3339)}, + } + st := session(t, f) + st.Record("aviorstudio/tetris", 900, "1.2.0", true) + + if _, err := syncActivity(st); err != nil { + t.Fatalf("sync: %v", err) + } + if got := st.High("aviorstudio/tetris"); got != 900 { + t.Errorf("local high = %d, want 900 — the account must not lower it", got) + } + if got := st.High("aviorstudio/asteroid"); got != 5000 { + t.Errorf("high = %d, want the 5000 set on another machine", got) + } +} + +// Signed out is the ordinary state of an arcade nobody has made an account +// for. It must not be an error, and the queue must survive it. +func TestSyncSignedOutIsNotAFailure(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + st, _ := scores.Load() + st.Record("aviorstudio/tetris", 900, "1.2.0", true) + + sent, err := syncActivity(st) + if err != nil || sent != 0 { + t.Fatalf("signed-out sync: sent=%d err=%v", sent, err) + } + if got := st.Pending(); len(got) != 1 { + t.Errorf("signing out ate the queue: %v", got) + } +} + +func TestSyncMessageOnlySpeaksWhenThereIsSomethingToSay(t *testing.T) { + if got := syncMessage(0, nil); got != "" { + t.Errorf("a quiet sync said %q", got) + } + if got := syncMessage(0, registry.ErrUnreachable); got != "" { + t.Errorf("being offline said %q; the arcade works offline", got) + } + if got := syncMessage(1, nil); got != "1 run synced to your account" { + t.Errorf("one run: %q", got) + } + if got := syncMessage(4, nil); got != "4 runs synced to your account" { + t.Errorf("several runs: %q", got) + } + // An expired session is the one failure somebody can do something about. + if got := syncMessage(0, registry.ErrLoginRequired); !strings.Contains(got, "expired") { + t.Errorf("expired session: %q", got) + } +} diff --git a/internal/engine/registration.go b/internal/engine/registration.go index 95ad223..4624b36 100644 --- a/internal/engine/registration.go +++ b/internal/engine/registration.go @@ -23,4 +23,9 @@ type Registration struct { // Installed marks a game loaded from the games directory rather than // compiled into the arcade. Installed bool + // Version is the package version from this game's manifest. It is not on + // sdk.Info because a guest has no business knowing which build of itself is + // running; the arcade needs it to report a run and to notice that the + // marketplace has moved on. + Version string } diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 016477c..4b1bdeb 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -78,7 +78,8 @@ func load(rt *Runtime, dir string) (engine.Registration, error) { info := m.Info() return engine.Registration{ - Info: info, + Info: info, + Version: m.Game.Version, New: func(shape sdk.CellShape) (sdk.Game, error) { wasm, err := os.ReadFile(wasmPath) if err != nil { diff --git a/internal/registry/client.go b/internal/registry/client.go index 120af8d..7727449 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -375,6 +375,60 @@ func (c *Client) Library() ([]Game, error) { return games, c.do(http.MethodGet, "/v1/library", nil, &games) } +// Activity is what the account has done with one game, wherever it did it. +// +// It is a personal record and not a score anybody is ranked by: the registry +// stores what a client reported about a game it ran in its own sandbox, and +// cannot check any of it. +type Activity struct { + ID string `json:"id"` // "author/slug" + PersonalBest int `json:"personal_best"` + Plays int `json:"plays"` + LastPlayed string `json:"last_played,omitempty"` // RFC 3339 + LastVersion string `json:"last_version,omitempty"` // package version of the newest play + LastComplete string `json:"last_completed,omitempty"` +} + +// PlayedAt parses LastPlayed, zero if there is none or it is unreadable. A +// timestamp the arcade cannot read is not worth failing a sync over. +func (a Activity) PlayedAt() time.Time { + parsed, err := time.Parse(time.RFC3339, a.LastPlayed) + if err != nil { + return time.Time{} + } + return parsed +} + +// Activity lists everything this account has played. It is not filtered to the +// library: removing a game does not erase its history, and a machine syncing +// for the first time wants all of it. +func (c *Client) Activity() ([]Activity, error) { + var out []Activity + return out, c.do(http.MethodGet, "/v1/activity", nil, &out) +} + +// Run is one finished run, as this arcade reports it. +type Run struct { + // Run identifies the run rather than the request. Reuse it for every + // attempt to deliver one run, or the registry counts each attempt as + // another play. + Run string `json:"run"` + Score int `json:"score"` + PlayedAt string `json:"played_at,omitempty"` // RFC 3339 + Completed bool `json:"completed"` + Version string `json:"version,omitempty"` +} + +// SubmitRun records a run against the account and returns the merged record. +// +// Safe to retry: every field the registry stores is merged with max(), so a +// submission that arrives twice, out of order, or a week after the fact cannot +// lower what is already there. +func (c *Client) SubmitRun(author, slug string, run Run) (Activity, error) { + var out Activity + return out, c.do(http.MethodPost, "/v1/activity/"+author+"/"+slug, run, &out) +} + // Key is a publish credential. Token is set only by CreateKey, in the one // response that carries it. type Key struct { diff --git a/internal/scores/scores.go b/internal/scores/scores.go index 0ed17a4..13967d0 100644 --- a/internal/scores/scores.go +++ b/internal/scores/scores.go @@ -1,7 +1,19 @@ // Package scores persists per-game high scores to the user config directory. +// +// This file is the arcade's own record and stays authoritative for playing: +// the games run with no network and no account, and a high score has to +// survive both. What an account adds is continuity between machines, and that +// is the queue below — runs waiting to reach the registry, and whatever the +// registry knows that this machine did not. +// +// The merge only ever raises a value. A high score set on one machine and a +// higher one set on another both survive; neither client has to be right about +// which is newer, and a sync that never happens costs nothing but the sharing. package scores import ( + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "os" @@ -15,15 +27,45 @@ type Entry struct { HighScore int `json:"high_score"` UpdatedAt time.Time `json:"updated_at"` LastPlayed time.Time `json:"last_played,omitzero"` + // Version is the package version of the last run recorded here. Compared + // with what the marketplace offers, it is how the library says a local copy + // has fallen behind. + Version string `json:"version,omitempty"` } +// Run is a finished run waiting to reach the account. +// +// ID identifies the RUN, not the request that carries it: it is generated once +// here and reused for every attempt, which is what lets the registry count a +// run once however many times a flaky connection delivers it. A run is dropped +// from the queue only when the registry has acknowledged it. +type Run struct { + ID string `json:"id"` + Game string `json:"game"` + Score int `json:"score"` + PlayedAt time.Time `json:"played_at"` + Completed bool `json:"completed"` + Version string `json:"version,omitempty"` +} + +// version 3: adds the pending-run queue and the per-entry package version. +// Version-2 files load unchanged — an absent queue is an empty one — and are +// rewritten as v3 on the next save. +// // version 2: game keys are namespaced "author/slug". Version-1 files predate // plugins, so every bare key in one is a builtin and migrates to "aviorstudio/". -const fileVersion = 2 +const fileVersion = 3 + +// maxPending bounds the queue. An arcade played offline for a long time should +// not accumulate an unbounded file, and the oldest runs are the ones whose loss +// costs least — their scores have already been merged into the local high, so +// what is dropped is a play count and a timestamp, not a record. +const maxPending = 500 type file struct { Version int `json:"version"` Games map[string]Entry `json:"games"` + Pending []Run `json:"pending,omitempty"` } // Store is safe for concurrent use: saves run off the UI loop. @@ -107,13 +149,122 @@ func (s *Store) LastPlayed(gameID string) time.Time { func (s *Store) Submit(gameID string, sc int) bool { s.mu.Lock() defer s.mu.Unlock() - if sc <= s.data.Games[gameID].HighScore { + return s.submit(gameID, sc) +} + +// submit is Submit without the lock. It updates the high score in place rather +// than replacing the entry: an assignment here used to discard LastPlayed, so +// finishing a game removed it from the recently-played list that starting it +// had just put it on. +func (s *Store) submit(gameID string, sc int) bool { + e := s.data.Games[gameID] + if sc <= e.HighScore { return false } - s.data.Games[gameID] = Entry{HighScore: sc, UpdatedAt: time.Now().UTC()} + e.HighScore = sc + e.UpdatedAt = time.Now().UTC() + s.data.Games[gameID] = e return true } +// Record is the end of a run: it updates the local high and queues the run for +// the account. version is the package version that was running, empty for a +// game whose manifest did not say. +// +// The queue is written whether or not anybody is signed in. Signing in later +// is what sends it, which is the point — the arcade is playable with no +// account and no network, and choosing to have one should not begin at zero. +func (s *Store) Record(gameID string, score int, version string, completed bool) (newHigh bool) { + s.mu.Lock() + defer s.mu.Unlock() + + newHigh = s.submit(gameID, score) + e := s.data.Games[gameID] + e.LastPlayed = time.Now().UTC() + if version != "" { + e.Version = version + } + s.data.Games[gameID] = e + + s.data.Pending = append(s.data.Pending, Run{ + ID: newRunID(), + Game: gameID, + Score: score, + PlayedAt: e.LastPlayed, + Completed: completed, + Version: version, + }) + if len(s.data.Pending) > maxPending { + s.data.Pending = s.data.Pending[len(s.data.Pending)-maxPending:] + } + return newHigh +} + +// Pending returns the queued runs, oldest first. The slice is a copy: a sync +// runs off the UI loop and must not hold a view into the store. +func (s *Store) Pending() []Run { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]Run, len(s.data.Pending)) + copy(out, s.data.Pending) + return out +} + +// Sent drops a run the registry has acknowledged. Anything it does not +// acknowledge stays queued, which is why a retry has to carry the same run id. +func (s *Store) Sent(runID string) { + s.mu.Lock() + defer s.mu.Unlock() + for i, r := range s.data.Pending { + if r.ID == runID { + s.data.Pending = append(s.data.Pending[:i], s.data.Pending[i+1:]...) + return + } + } +} + +// Merge folds what the account knows into this machine's record. Nothing is +// lowered: a high set here that the registry has not heard about yet survives, +// and so does one set on another machine. +// +// The version is not merged. It describes the package that ran on THIS +// machine, and adopting another's would report a copy as current that is not +// installed here. +func (s *Store) Merge(gameID string, high int, lastPlayed time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + e := s.data.Games[gameID] + if high > e.HighScore { + e.HighScore = high + e.UpdatedAt = time.Now().UTC() + } + if lastPlayed.After(e.LastPlayed) { + e.LastPlayed = lastPlayed + } + s.data.Games[gameID] = e +} + +// Version reports the package version of the last run recorded for a game. +func (s *Store) Version(gameID string) string { + s.mu.Lock() + defer s.mu.Unlock() + return s.data.Games[gameID].Version +} + +// newRunID is a run's identity across every attempt to deliver it. Hex, because +// the registry accepts lowercase letters and digits and there is no reason to +// spend the alphabet. +func newRunID() string { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + // crypto/rand does not fail in practice, and a run that cannot be + // identified is better sent under a time-based id than dropped: the + // worst case is a duplicate play counted after a retry. + return fmt.Sprintf("run%d", time.Now().UnixNano()) + } + return hex.EncodeToString(raw[:]) +} + // Save writes the store atomically (temp file + rename). func (s *Store) Save() error { if s.path == "" { diff --git a/internal/scores/scores_test.go b/internal/scores/scores_test.go index cffb740..7c4ac47 100644 --- a/internal/scores/scores_test.go +++ b/internal/scores/scores_test.go @@ -3,7 +3,9 @@ package scores import ( "os" "path/filepath" + "strings" "testing" + "time" ) func setupTempConfig(t *testing.T) string { @@ -125,3 +127,204 @@ func TestLoadCorruptFile(t *testing.T) { t.Errorf("recovered high = %d, want 7", got) } } + +// ------------------------------------------------------ runs and the queue -- + +func TestRecordQueuesTheRunAndKeepsTheHigh(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + + if !s.Record("aviorstudio/tetris", 900, "1.2.0", true) { + t.Error("first run not reported as a new high") + } + if s.Record("aviorstudio/tetris", 10, "1.2.0", true) { + t.Error("a worse run reported as a new high") + } + + if got := s.High("aviorstudio/tetris"); got != 900 { + t.Errorf("high = %d, want 900 — a worse run must not lower it", got) + } + if got := s.Version("aviorstudio/tetris"); got != "1.2.0" { + t.Errorf("version = %q, want the package that ran", got) + } + + pending := s.Pending() + if len(pending) != 2 { + t.Fatalf("queued %d runs, want both", len(pending)) + } + // Both runs happened, so both are owed to the account — the queue is a + // record of runs, not of high scores. + if pending[0].Score != 900 || pending[1].Score != 10 { + t.Errorf("queue is not in the order they were played: %v", pending) + } + if pending[0].ID == pending[1].ID { + t.Error("two runs share an id; the registry would count them as one") + } + for _, r := range pending { + if len(r.ID) < 8 { + t.Errorf("run id %q is shorter than the registry accepts", r.ID) + } + } +} + +// Playing must not depend on an account existing, and a run played before +// there is one is exactly what signing in should carry over. +func TestQueueSurvivesASaveAndReload(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + s.Record("aviorstudio/tetris", 900, "1.2.0", true) + if err := s.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + s2, err := Load() + if err != nil { + t.Fatalf("re-Load: %v", err) + } + pending := s2.Pending() + if len(pending) != 1 || pending[0].Score != 900 { + t.Fatalf("queue did not survive a restart: %v", pending) + } + + s2.Sent(pending[0].ID) + if got := s2.Pending(); len(got) != 0 { + t.Errorf("acknowledged run still queued: %v", got) + } + // Acknowledging a run does not touch the score it set. + if got := s2.High("aviorstudio/tetris"); got != 900 { + t.Errorf("high = %d after the run was sent, want 900", got) + } +} + +func TestSentIgnoresARunItDoesNotHave(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + s.Record("aviorstudio/tetris", 10, "", true) + s.Sent("a-run-from-somewhere-else") + if got := s.Pending(); len(got) != 1 { + t.Errorf("queue changed: %v", got) + } +} + +func TestQueueIsBounded(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + for i := range maxPending + 50 { + s.Record("aviorstudio/tetris", i, "", true) + } + pending := s.Pending() + if len(pending) != maxPending { + t.Fatalf("queue holds %d runs, want it capped at %d", len(pending), maxPending) + } + // The oldest go first: their scores are already in the local high, so what + // is dropped is a play count rather than a record. + if pending[0].Score != 50 { + t.Errorf("dropped from the wrong end: oldest kept run scores %d", pending[0].Score) + } + if got := s.High("aviorstudio/tetris"); got != maxPending+49 { + t.Errorf("high = %d; dropping queued runs must not touch it", got) + } +} + +// ------------------------------------------------------------------ merging -- + +func TestMergeOnlyRaises(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + played := time.Now().UTC().Add(-time.Hour) + s.Record("aviorstudio/tetris", 900, "1.2.0", true) + + // The account knows less than this machine does. + s.Merge("aviorstudio/tetris", 10, played.Add(-48*time.Hour)) + if got := s.High("aviorstudio/tetris"); got != 900 { + t.Errorf("high = %d, want 900 — the account must not lower a local high", got) + } + if s.LastPlayed("aviorstudio/tetris").Before(played) { + t.Error("an older remote timestamp moved last played backwards") + } + + // And more than it does. + s.Merge("aviorstudio/tetris", 5000, time.Now().UTC()) + if got := s.High("aviorstudio/tetris"); got != 5000 { + t.Errorf("high = %d, want the account's 5000", got) + } +} + +// A game only ever played on another machine arrives with nothing local to +// merge into, and has to appear anyway — that is the whole point of syncing. +func TestMergeIntroducesAGameThisMachineHasNotPlayed(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + when := time.Now().UTC().Add(-time.Hour) + s.Merge("aviorstudio/asteroid", 700, when) + + if got := s.High("aviorstudio/asteroid"); got != 700 { + t.Errorf("high = %d, want 700", got) + } + if got := s.LastPlayed("aviorstudio/asteroid"); !got.Equal(when) { + t.Errorf("last played = %s, want %s", got, when) + } +} + +// The version describes the package installed HERE. Adopting the account's +// would report a copy as current that this machine does not have. +func TestMergeDoesNotAdoptTheAccountsVersion(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + s.Record("aviorstudio/tetris", 100, "1.0.0", true) + s.Merge("aviorstudio/tetris", 900, time.Now().UTC()) + + if got := s.Version("aviorstudio/tetris"); got != "1.0.0" { + t.Errorf("version = %q, want the locally installed 1.0.0", got) + } +} + +// Finishing a game used to wipe the timestamp that starting it had just set, +// which took the game straight back off the recently-played list. +func TestFinishingAGameKeepsItRecentlyPlayed(t *testing.T) { + setupTempConfig(t) + s, _ := Load() + s.Touch("aviorstudio/tetris") + s.Submit("aviorstudio/tetris", 900) + + if s.LastPlayed("aviorstudio/tetris").IsZero() { + t.Error("a new high score erased when the game was last played") + } +} + +// A v2 file has no queue. It must load as one with an empty queue rather than +// as a failure, and save forward as v3. +func TestV2FileLoadsWithAnEmptyQueue(t *testing.T) { + dir := setupTempConfig(t) + path := filepath.Join(dir, "termcade", "scores.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + v2 := `{"version":2,"games":{"aviorstudio/tetris":{"high_score":900}}}` + if err := os.WriteFile(path, []byte(v2), 0o644); err != nil { + t.Fatal(err) + } + + s, err := Load() + if err != nil { + t.Fatalf("Load v2 file: %v", err) + } + if got := s.High("aviorstudio/tetris"); got != 900 { + t.Errorf("high = %d, want 900", got) + } + if got := s.Pending(); len(got) != 0 { + t.Errorf("queue = %v, want empty", got) + } + + s.Record("aviorstudio/tetris", 1000, "1.0.0", true) + if err := s.Save(); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"version": 3`) { + t.Errorf("saved file is not v3: %s", raw) + } +} diff --git a/internal/shell/library.go b/internal/shell/library.go index 1100b60..af2e456 100644 --- a/internal/shell/library.go +++ b/internal/shell/library.go @@ -5,11 +5,39 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/aviorstudio/termcade/internal/engine" ) +// updateStyle marks a game the marketplace has moved past. Green rather than +// the notice yellow: an update is available, not a problem. +var updateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4fc964")) + // The library is every game in the arcade — builtins and installed — with // broken installs listed dimmed so the player sees why they are missing. +// updateAvailable reports the newer version the marketplace publishes for an +// installed game, if there is one. +// +// It compares the version in the package's own manifest with the catalog's, +// and says nothing at all when either is unknown: a game whose manifest omits +// a version, or a catalog nothing has loaded yet, is not evidence that an +// update exists. Inequality rather than ordering, because a player running +// something newer than the marketplace has is a developer, and telling them to +// "update" to their own older release would be wrong in the other direction — +// so the marker names the version rather than commanding anything. +func (m Model) updateAvailable(g engine.Registration) (string, bool) { + if g.Err != nil || g.Version == "" { + return "", false + } + newer, ok := m.latest[g.Info.ID] + if !ok || newer == "" || newer == g.Version { + return "", false + } + return newer, true +} + func (m Model) updateLibraryKey(key string) (tea.Model, tea.Cmd) { switch key { case "esc", "q": @@ -60,6 +88,9 @@ func (m Model) viewLibrary() string { if high := m.scores.High(g.Info.ID); high > 0 { line += dimStyle.Render(" ··· high " + strconv.Itoa(high)) } + if newer, ok := m.updateAvailable(g); ok { + line += updateStyle.Render(" ··· v" + newer + " available") + } if i == m.libIdx { b.WriteString(selectedStyle.Render("▸" + line[1:])) } else { diff --git a/internal/shell/market.go b/internal/shell/market.go index c1217c8..f3af8a1 100644 --- a/internal/shell/market.go +++ b/internal/shell/market.go @@ -32,6 +32,18 @@ type Marketplace struct { SignOut func() error // Reload re-discovers installed games after an install/remove. Reload func() []engine.Registration + // Sync flushes queued runs to the account, folds the account's record back + // into the local one, and reports the newest published version of each + // game so the library can say which installs have fallen behind. + // + // The notice is what to show, or "" for the ordinary case where nothing + // happened and nobody needs telling. + // + // It returns no error on purpose. Being signed out, being offline, and + // having nothing to send are all the normal state of an arcade that works + // without an account — none of them is a failure worth interrupting + // somebody over, and the queue survives all three. + Sync func() (notice string, latest map[string]string) } type marketLoadedMsg struct { @@ -47,6 +59,13 @@ type marketOpMsg struct { type authDoneMsg struct{ err error } +// syncedMsg carries whatever a sync wants said, which is usually nothing, and +// what the marketplace currently publishes. +type syncedMsg struct { + notice string + latest map[string]string +} + type marketState struct { games []MarketGame loaded bool @@ -106,6 +125,20 @@ func (m Model) removeCmd(id string) tea.Cmd { } } +// syncCmd runs a sync off the UI loop. A nil Marketplace or a nil Sync hook +// yields no command at all, so an arcade built without one is not paying for a +// goroutine per game over. +func (m Model) syncCmd() tea.Cmd { + if m.mp == nil || m.mp.Sync == nil { + return nil + } + mp := m.mp + return func() tea.Msg { + notice, latest := mp.Sync() + return syncedMsg{notice: notice, latest: latest} + } +} + func (m Model) authCmd(signup bool, username, email, password string) tea.Cmd { mp := m.mp return func() tea.Msg { @@ -116,6 +149,18 @@ func (m Model) authCmd(signup bool, username, email, password string) tea.Cmd { } } +// latestVersions is what the marketplace currently publishes, by game id. +// Games with no release are left out — there is no version to fall behind. +func latestVersions(games []MarketGame) map[string]string { + out := make(map[string]string, len(games)) + for _, g := range games { + if g.HasPackage && g.Version != "" { + out[g.ID] = g.Version + } + } + return out +} + // installedIDs is derived from the menu's registrations, so marketplace // markers always agree with what the menu shows. func (m Model) installedIDs() map[string]bool { @@ -141,6 +186,10 @@ func (m Model) updateMarketMsg(msg tea.Msg) (Model, tea.Cmd, bool) { return m, nil, true } m.market.games = msg.games + // The catalog is the same answer a sync fetches, so reading it here + // keeps the library's update markers current for a player who browsed + // rather than one who waited for a sync. + m.latest = latestVersions(msg.games) if m.market.idx >= len(msg.games) { m.market.idx = 0 } @@ -171,6 +220,20 @@ func (m Model) updateMarketMsg(msg tea.Msg) (Model, tea.Cmd, bool) { } m.screen = screenMarket m.market.notice = "signed in" + // The moment an account exists is the moment everything played + // without one has somewhere to go. + return m, m.syncCmd(), true + case syncedMsg: + if len(msg.latest) > 0 { + m.latest = msg.latest + } + // Silence is the common case and is left alone: overwriting a notice + // the player is reading with nothing would be worse than saying + // nothing. + if msg.notice != "" { + m.notice = msg.notice + m.market.notice = msg.notice + } return m, nil, true } return m, nil, false diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 637f9e9..34744ac 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -85,6 +85,10 @@ type Model struct { notice string // menu-level message, e.g. a game that failed to load crash string // what the crash screen shows mp *Marketplace + // latest is the newest published version of each game, by id, as of the + // last sync or marketplace load. Empty until one has happened, which is + // why nothing claims an update is available before then. + latest map[string]string market marketState auth authState libIdx int @@ -100,7 +104,11 @@ func New(games []engine.Registration, st *scores.Store, shape sdk.CellShape, mp return Model{games: games, scores: st, shape: shape, mp: mp} } -func (m Model) Init() tea.Cmd { return nil } +// Init syncs once at startup, which is where an arcade that was played offline +// catches up and where one on a new machine learns what the account already +// knows. It runs off the UI loop like every other marketplace call, so a slow +// or absent network delays nothing. +func (m Model) Init() tea.Cmd { return m.syncCmd() } func (m Model) tick() tea.Cmd { gen := m.tickGen @@ -177,9 +185,15 @@ func (m Model) updateTick(msg tickMsg) (tea.Model, tea.Cmd) { return mm, nil } if status == sdk.StatusGameOver { - m.newHigh = m.scores.Submit(m.game.Info().ID, m.game.Score()) + // Recorded, not merely scored: this also queues the run for the + // account, which is what carries the same arcade to another + // machine. It queues signed out too — signing in later sends it. + m.newHigh = m.scores.Record( + m.game.Info().ID, m.game.Score(), m.games[m.gameIdx].Version, true) m.screen = screenGameOver - return m, saveScores(m.scores) // persisted off the tick path; also saved on quit + // Saved off the tick path, and synced after it; neither may stall + // the loop the player is still looking at. + return m, tea.Batch(saveScores(m.scores), m.syncCmd()) } } if stepped { diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index 6f896dd..8fccc16 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -704,3 +704,177 @@ func TestPixelToggle(t *testing.T) { t.Fatalf("canvas not rebuilt: shape = %q", m.canvas.Shape().Name) } } + +// -------------------------------------------------------- play continuity -- + +// syncingMarket is a Marketplace whose only interesting hook is Sync. +func syncingMarket(calls *int, notice string, latest map[string]string) *Marketplace { + signedIn := false + var installed []engine.Registration + mp := fakeMarket(&signedIn, &installed) + mp.Sync = func() (string, map[string]string) { + *calls++ + return notice, latest + } + return mp +} + +func newSyncShell(t *testing.T, mp *Marketplace, regs ...engine.Registration) Model { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + st, err := scores.Load() + if err != nil { + t.Fatal(err) + } + if len(regs) == 0 { + g := &fakeGame{} + regs = []engine.Registration{{ + Info: g.Info(), + New: func(sdk.CellShape) (sdk.Game, error) { return g, nil }, + }} + } + m := New(regs, st, sdk.Quadrant, mp) + mm, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + return mm.(Model) +} + +// An arcade played offline catches up when it starts, not when somebody +// remembers to ask it to. +func TestInitSyncs(t *testing.T) { + calls := 0 + m := newSyncShell(t, syncingMarket(&calls, "", nil)) + + cmd := m.Init() + if cmd == nil { + t.Fatal("Init issued no sync command") + } + cmd() + if calls != 1 { + t.Errorf("sync ran %d times at startup, want 1", calls) + } +} + +// A shell with no marketplace has nothing to sync to, and must not go looking. +func TestInitWithoutAMarketplaceDoesNothing(t *testing.T) { + m := newTestShell(t, &fakeGame{}) + if m.Init() != nil { + t.Error("an arcade with no marketplace issued a sync") + } +} + +func TestFinishingARunQueuesItAndSyncs(t *testing.T) { + calls := 0 + g := &fakeGame{endAfter: 1, score: 42} + m := newSyncShell(t, syncingMarket(&calls, "", nil), engine.Registration{ + Info: g.Info(), + New: func(sdk.CellShape) (sdk.Game, error) { return g, nil }, + Version: "1.2.0", + }) + + m, _ = step(t, m, key("l")) // library + m, _ = step(t, m, key("enter")) // start the game + m, cmd := tickNow(t, m) + if m.screen != screenGameOver { + t.Fatalf("screen = %v, want game over", m.screen) + } + + pending := m.scores.Pending() + if len(pending) != 1 { + t.Fatalf("queued %d runs, want the one just played", len(pending)) + } + if pending[0].Score != 42 || !pending[0].Completed { + t.Errorf("run recorded wrong: %+v", pending[0]) + } + // The version comes off the registration, so the account learns which + // package produced the score. + if pending[0].Version != "1.2.0" { + t.Errorf("run version = %q, want the installed 1.2.0", pending[0].Version) + } + + if cmd == nil { + t.Fatal("game over issued no command") + } + drain(t, m, cmd) + if calls != 1 { + t.Errorf("sync ran %d times after a run, want 1", calls) + } +} + +// Signing in is the moment everything played without an account has somewhere +// to go. +func TestSigningInSyncs(t *testing.T) { + calls := 0 + m := newSyncShell(t, syncingMarket(&calls, "", nil)) + + m.screen = screenAuth + m, cmd := step(t, m, authDoneMsg{}) + if m.screen != screenMarket { + t.Fatalf("screen = %v after signing in", m.screen) + } + if cmd == nil { + t.Fatal("signing in issued no sync") + } + // Batched with the page-clear the route change adds, so it has to be run + // as a chain rather than called. + drain(t, m, cmd) + if calls != 1 { + t.Errorf("sync ran %d times after sign-in, want 1", calls) + } +} + +func TestSyncNoticeReachesTheScreen(t *testing.T) { + calls := 0 + m := newSyncShell(t, syncingMarket(&calls, "", nil)) + + m, _ = step(t, m, syncedMsg{notice: "2 runs synced to your account"}) + if !strings.Contains(view(m), "2 runs synced") { + t.Errorf("sync notice not shown:\n%s", view(m)) + } + + // A quiet sync leaves a notice somebody may be reading alone. + m, _ = step(t, m, syncedMsg{}) + if !strings.Contains(view(m), "2 runs synced") { + t.Errorf("a quiet sync erased the previous notice:\n%s", view(m)) + } +} + +func TestLibraryMarksAnOutdatedInstall(t *testing.T) { + calls := 0 + g := &fakeGame{} + m := newSyncShell(t, syncingMarket(&calls, "", nil), engine.Registration{ + Info: g.Info(), + New: func(sdk.CellShape) (sdk.Game, error) { return g, nil }, + Version: "1.0.0", + Installed: true, + }) + + // Nothing is claimed before a sync has said what the marketplace holds. + m, _ = step(t, m, key("l")) + if strings.Contains(view(m), "available") { + t.Errorf("an update was claimed before any catalog was loaded:\n%s", view(m)) + } + + m, _ = step(t, m, syncedMsg{latest: map[string]string{"fake": "2.0.0"}}) + if !strings.Contains(view(m), "v2.0.0 available") { + t.Errorf("library does not mark the outdated install:\n%s", view(m)) + } + + // A game already on the newest version says nothing at all. + m, _ = step(t, m, syncedMsg{latest: map[string]string{"fake": "1.0.0"}}) + if strings.Contains(view(m), "available") { + t.Errorf("a current install was marked as outdated:\n%s", view(m)) + } +} + +// A game whose manifest carries no version cannot be compared, and guessing +// would put an update marker on every game that omits one. +func TestAVersionlessGameIsNeverMarkedOutdated(t *testing.T) { + calls := 0 + m := newSyncShell(t, syncingMarket(&calls, "", nil)) + + m, _ = step(t, m, syncedMsg{latest: map[string]string{"fake": "2.0.0"}}) + m, _ = step(t, m, key("l")) + if strings.Contains(view(m), "available") { + t.Errorf("a game with no version was marked outdated:\n%s", view(m)) + } +} diff --git a/main.go b/main.go index 93df8a9..1f4e786 100644 --- a/main.go +++ b/main.go @@ -66,7 +66,7 @@ func main() { defer rt.Close() games := discoverGames(rt) - p := tea.NewProgram(shell.New(games, st, shape, newMarketplace(rt))) + p := tea.NewProgram(shell.New(games, st, shape, newMarketplace(rt, st))) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, "termcade:", err) os.Exit(1) diff --git a/market.go b/market.go index 3e0c0e5..c51d01d 100644 --- a/market.go +++ b/market.go @@ -8,13 +8,14 @@ import ( "github.com/aviorstudio/termcade/internal/engine" "github.com/aviorstudio/termcade/internal/plugin" "github.com/aviorstudio/termcade/internal/registry" + "github.com/aviorstudio/termcade/internal/scores" "github.com/aviorstudio/termcade/internal/shell" ) // newMarketplace wires the arcade's marketplace screens to the registry and // the local install machinery. Every hook is TUI-safe: no printing, errors // returned for the shell to display. -func newMarketplace(rt *plugin.Runtime) *shell.Marketplace { +func newMarketplace(rt *plugin.Runtime, st *scores.Store) *shell.Marketplace { anonClient := func() *registry.Client { session, _ := registry.LoadSession() token := "" @@ -119,5 +120,43 @@ func newMarketplace(rt *plugin.Runtime) *shell.Marketplace { SignOut: registry.ClearSession, Reload: reload, + + // Sync is the whole of continuity from the shell's side: queued runs + // out, the account's record in, and the catalog's versions back so the + // library can say what has moved on. + // + // Nothing here reports a failure. An arcade with no account and no + // network is a supported way to use this, not an error state, and the + // catalog is fetched separately from the account half so being signed + // out still gets update markers. + Sync: func() (string, map[string]string) { + sent, err := syncActivity(st) + if err == nil && sent > 0 { + // Only worth the disk write when something actually changed. + st.Save() + } + return syncMessage(sent, err), latestVersions() + }, + } +} + +// latestVersions asks the catalog what each game currently publishes. It is +// anonymous — browsing always is — so an arcade nobody has signed into still +// learns that its copy of a game is behind. +// +// A failure is an empty map rather than an error: not knowing means saying +// nothing, which is what the library does with an absent entry. +func latestVersions() map[string]string { + session, _ := registry.LoadSession() + games, err := registry.New(registry.URL(session), "").Games() + if err != nil { + return nil + } + out := make(map[string]string, len(games)) + for _, g := range games { + if g.HasPackage && g.Version != "" { + out[g.ID] = g.Version + } } + return out }