Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
118 changes: 118 additions & 0 deletions activity.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
211 changes: 211 additions & 0 deletions activity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions internal/engine/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion internal/plugin/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading