diff --git a/CHANGELOG.md b/CHANGELOG.md index 9305c75..0534cf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,17 +3,19 @@ All notable changes to RFoG. Dates are UTC. While RFoG is in alpha, rules, numbers and ratings can change (or be reset) between versions. -## 0.1.0-alpha: first public release (unreleased) +## 0.0.3 (unreleased) The first version anyone else plays: the whole game, in a browser and in a -terminal. +terminal. (0.0.2 was the first tagged build: the launch code plus the first +two contributions.) ### The game - WEGO squad tactics: both sides plan in secret, then the turn resolves at once (instant abilities, delayed abilities, movement, overwatch, attacks, scoring), with initiative, height, cover, line of sight and fog. -- An 8×8 board for 1v1 with a raised core and two side objectives; larger - maps for 2v2 to 5v5. +- An 8×8 board for 1v1 with a raised core and two side objectives. + Experimental team modes (2v2 to 5v5) in the terminal client, not yet + balanced. - Net scoring: each turn the side holding more objectives scores the difference; a commander kill is worth 2; first to 5, or the lead after 12 turns. @@ -50,11 +52,17 @@ terminal. ### Terminal - Plays everything by keyboard (mouse and touch also work), from 80×24 monochrome up to truecolor; the same pixel figures in half blocks on big - tiles, damage and healing numbers, fibre runs under the floor, the - `fibre` theme matching the web app, and five others. + tiles, damage and healing numbers, fibre runs under the floor. +- Ten boards, the same in the terminal and the web app (fibre, graphite, + daylight, abyss, nord, gruvbox, catppuccin, tokyo-night, amber, green; + tokyo-night from a contributor's pull request), each readable + at 24-bit and 256 colours; plus mono for terminals without colour. A + custom `~/.config/rfog/theme.toml` makes a board from `light`, `dark` and + `wall`. - A home screen laid out like the web app's (quick pairing grid, rated or - casual, side column, your games, leaderboard) on big terminals; a list on - small ones. + casual, side column, your games, leaderboard, and the top bar: Watch, + Learn, Leaderboard) on big terminals; a list on small ones. Back from an + online screen returns there. - Tutorial, hotseat, roster, replays, settings, a command line (`:`). ### Online @@ -65,7 +73,7 @@ terminal. 120s+ (long-haul, with time banks), 24h (daily), each **rated or casual**. Glicko-2 ratings per category. Casual games welcome guests and fill empty seats with bots after a wait. -- Draft with bans; 1v1 to 5v5 (team modes in the terminal for now). +- Draft with bans (each player bans one commander, then picks). - **Challenge a friend** by link (web) or code (terminal); the codes work in both. Challenges are casual: ratings come from pairing only. - **Resume anywhere, play in one place:** a match follows the account between @@ -91,3 +99,7 @@ terminal. - One static binary for Linux, macOS, FreeBSD and Windows; SQLite or Postgres; TLS; the rules travel to clients when they differ from the client's own. +- Counts for the operator, with nothing kept about any player: the server + logs a `stats` line every 10 minutes (online, peak, by client, matches), + and `rfog stats` prints accounts, guests, online matches and players per + day from the store. diff --git a/README.md b/README.md index 3669f9c..b068845 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ Free and open source. Play in the browser or in a terminal, on one account.

-The web app on a phone: quick pairing by clock, bots, pass and play, challenges -A match on a phone: the squad holding a contested objective -A replay on a phone: an ability's blast and the damage it does +The web app on a phone: quick pairing by clock, bots, pass and play, challenges +A match on a phone: the squad holding a contested objective +A replay on a phone: an ability's blast and the damage it does

## About @@ -28,15 +28,15 @@ over signal nodes. Both sides give their orders in secret, then the turn resolves for everyone at once. A match takes 10 to 15 minutes. There is nothing to buy and nothing to unlock. Every commander is available -to every player. The project is funded by donations, as Lichess is. +to every player. No ads and no paid features, on the Lichess model. ### Features - **Simultaneous turns.** Orders are planned in secret and resolved together in a fixed order: instant abilities, delayed abilities, movement, overwatch, attacks, scoring. -- **Ten commanders,** each with four abilities, chosen in a draft with bans. - Modes from 1v1 to 5v5. +- **1v1, ten commanders.** Each commander has four abilities; both players + ban one, then pick. - **Two clients, one game.** A graphical web app for desktop and phone, and a terminal client that runs natively or over SSH with nothing to install. A match started on one continues on the other. @@ -58,12 +58,12 @@ to every player. The project is funded by donations, as Lichess is. ## The terminal client

-A match in the terminal client: the board with pixel figures, the unit panel and the squad +A match in the terminal client: the board with pixel figures, the unit panel and the squad

-The terminal home screen: quick pairing, games and leaderboard -The commander roster in the terminal +The terminal home screen: quick pairing, games and leaderboard +The commander roster in the terminal

The terminal client draws the same pixel figures as the web app in half @@ -119,6 +119,17 @@ connects. ## Build and test +To work on it, with Go 1.25 or newer: + +```sh +git clone https://github.com/rfog-org/rfog.git && cd rfog +go test ./... # the tests +go build -o rfog ./cmd/rfog +./rfog serve # a local server; the web app at http://localhost:8080/ +``` + +Then, from the repository: + ```sh make # vet, test, build make web # rebuild web/engine.wasm after engine or data changes @@ -135,7 +146,7 @@ to change balance. ## Status -**Alpha.** The game is complete and playable. Numbers are still being +**Alpha.** 1v1 is complete and playable. Numbers are still being tuned, and ratings may be reset before the beta. The [changelog](CHANGELOG.md) lists what each version contains. @@ -155,20 +166,3 @@ and monochrome portraits are from [Tiny Dungeon](https://kenney.nl/assets/tiny-d by Kenney (CC0). Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), [lipgloss](https://github.com/charmbracelet/lipgloss) and [wish](https://github.com/charmbracelet/wish). - -## Development setup - -To work on RFoG locally: - -1. Install Go 1.25 or newer. -2. Clone the repository: - git clone https://github.com/rfog-org/rfog.git - cd rfog -3. Run the test suite: - go test ./... -4. Build the project: - go build -o rfog ./cmd/rfog -5. Start the local server: - ./rfog serve - -For changes to the web client or game engine, see the contributing guide for the project layout and development workflow. \ No newline at end of file diff --git a/client/challenge.go b/client/challenge.go index 939c93a..d0e0373 100644 --- a/client/challenge.go +++ b/client/challenge.go @@ -49,6 +49,7 @@ func (o *onlineScreen) challengeKey(a *App, k tea.KeyMsg) (screen, tea.Cmd) { switch { case isKey(k, "esc"): o.state = "menu" + return o.homeOr(a, o), nil case isKey(k, "up", "down", "tab", "shift+tab"): c.sel = 1 - c.sel case isKey(k, "enter"): diff --git a/client/config.go b/client/config.go index 8f7a953..5cce0e1 100644 --- a/client/config.go +++ b/client/config.go @@ -106,9 +106,13 @@ func (s Settings) Save() error { } // themeFile is the on-disk custom theme format (~/.config/rfog/theme.toml). +// light and dark (and optionally wall) make a board the way the built-in +// ones are made (render.BoardTheme); any other key overrides one colour. type themeFile struct { Name string `toml:"name"` Mono bool `toml:"mono"` + Light string `toml:"light"` + Dark string `toml:"dark"` Fg string `toml:"fg"` Dim string `toml:"dim"` Accent string `toml:"accent"` @@ -139,15 +143,22 @@ func LoadUserTheme() string { if _, err := toml.Decode(string(b), &tf); err != nil || tf.Name == "" { return "" } - base := render.GetTheme("gruvbox") - t := render.Theme{Name: tf.Name, Mono: tf.Mono, Fg: or(tf.Fg, base.Fg), Dim: or(tf.Dim, base.Dim), - Accent: or(tf.Accent, base.Accent), TeamA: or(tf.TeamA, base.TeamA), TeamB: or(tf.TeamB, base.TeamB), - Danger: or(tf.Danger, base.Danger), Warn: or(tf.Warn, base.Warn), Good: or(tf.Good, base.Good), - Objective: or(tf.Objective, base.Objective), Smoke: or(tf.Smoke, base.Smoke), Wall: or(tf.Wall, base.Wall), - Cover: or(tf.Cover, base.Cover), Fog: or(tf.Fog, base.Fog), Cursor: or(tf.Cursor, base.Cursor), - Highlight: or(tf.Highlight, base.Highlight), Border: or(tf.Border, base.Border)} - for i := range t.Ground { - t.Ground[i] = or(tf.Ground[i], base.Ground[i]) + t := render.GetTheme("fibre") + if tf.Light != "" && tf.Dark != "" { + t = render.BoardTheme(render.Board{Name: tf.Name, Light: tf.Light, Dark: tf.Dark, Wall: or(tf.Wall, t.Wall)}) + } + t.Name, t.Mono = tf.Name, tf.Mono + for _, f := range []struct { + to *string + v string + }{ + {&t.Fg, tf.Fg}, {&t.Dim, tf.Dim}, {&t.Accent, tf.Accent}, {&t.TeamA, tf.TeamA}, {&t.TeamB, tf.TeamB}, + {&t.Danger, tf.Danger}, {&t.Warn, tf.Warn}, {&t.Good, tf.Good}, {&t.Objective, tf.Objective}, + {&t.Smoke, tf.Smoke}, {&t.Wall, tf.Wall}, {&t.Cover, tf.Cover}, {&t.Fog, tf.Fog}, {&t.Cursor, tf.Cursor}, + {&t.Highlight, tf.Highlight}, {&t.Border, tf.Border}, + {&t.Ground[0], tf.Ground[0]}, {&t.Ground[1], tf.Ground[1]}, {&t.Ground[2], tf.Ground[2]}, {&t.Ground[3], tf.Ground[3]}, + } { + *f.to = or(f.v, *f.to) } render.RegisterTheme(t) return t.Name diff --git a/client/draft.go b/client/draft.go index 75a2060..f68d62a 100644 --- a/client/draft.go +++ b/client/draft.go @@ -69,7 +69,7 @@ func (d *draftScreen) update(a *App, msg tea.Msg) (screen, tea.Cmd) { m := newMatchScreen(a, d.rm) return m.beginOrders(a, &t) case matchEndMsg: - return newOnlineScreen(a), nil + return onlineBack(a), nil case tea.KeyMsg: heroes := d.st.Heroes if len(heroes) == 0 { @@ -90,7 +90,7 @@ func (d *draftScreen) update(a *App, msg tea.Msg) (screen, tea.Cmd) { case isKey(t, "Q"): d.rm.Leave() a.rm = nil - return newOnlineScreen(a), nil + return onlineBack(a), nil } } return d, nil diff --git a/client/home.go b/client/home.go index 59191b0..136e180 100644 --- a/client/home.go +++ b/client/home.go @@ -44,7 +44,11 @@ func (m *menuScreen) gridItems(a *App) []menuItem { side := []menuItem{ {label: "play against bots", enabled: true, kind: "side", act: func(a *App) screen { return newPickScreen(a, matchBots) }}, {label: "pass and play", enabled: true, kind: "side", act: func(a *App) screen { return newPickScreen(a, matchHotseat) }}, - {label: "challenge a friend", enabled: true, kind: "side", act: func(a *App) screen { o := newOnlineScreen(a); o.thenChallenge = true; return o }}, + {label: "challenge a friend", enabled: true, kind: "side", act: func(a *App) screen { + o := newOnlineScreen(a) + o.thenChallenge, o.fromHome = true, true + return o + }}, {label: "how to play", enabled: true, kind: "side", act: func(a *App) screen { s, err := newTutorial(a) if err != nil { @@ -55,15 +59,6 @@ func (m *menuScreen) gridItems(a *App) []menuItem { } more := []menuItem{ {label: "online", enabled: true, kind: "more", act: func(a *App) screen { return newOnlineScreen(a) }}, - {label: "watch", enabled: true, kind: "more", act: func(a *App) screen { - if a.net != nil { - return newLobbyScreen(a) - } - o := newOnlineScreen(a) - o.thenWatch = true - return o - }}, - {label: "roster", enabled: true, kind: "more", act: func(a *App) screen { return newRosterScreen(a) }}, {label: "replays", enabled: true, kind: "more", act: func(a *App) screen { if a.ephemeral { return newMenuScreen() @@ -73,7 +68,35 @@ func (m *menuScreen) gridItems(a *App) []menuItem { {label: "settings", enabled: true, kind: "more", act: func(a *App) screen { return newSettingsScreen(a) }}, {label: "quit", enabled: true, kind: "more", act: func(a *App) screen { return nil }}, } - return append(append(out, side...), more...) + // The top bar, as the web app's: this page, then watch, learn (the + // commanders) and the leaderboard. + nav := []menuItem{ + {label: "play", enabled: true, kind: "nav", act: func(a *App) screen { return m }}, + {label: "watch", enabled: true, kind: "nav", act: homeWatch}, + {label: "learn", enabled: true, kind: "nav", act: func(a *App) screen { return newRosterScreen(a) }}, + {label: "leaderboard", enabled: true, kind: "nav", act: homeLadder}, + } + return append(append(append(out, side...), more...), nav...) +} + +// homeWatch is the live matches, signing in first if need be. +func homeWatch(a *App) screen { + if a.net != nil { + return newLobbyScreen(a) + } + o := newOnlineScreen(a) + o.thenWatch = true + return o +} + +// homeLadder is the leaderboard, signing in first if need be. +func homeLadder(a *App) screen { + if a.net != nil { + return newLadderScreen(a, "blitz") + } + o := newOnlineScreen(a) + o.thenLadder = true + return o } // layout picks the grid or the list for this screen, keeping the @@ -106,7 +129,7 @@ func (m *menuScreen) layout(a *App) { // gridKey moves through the home screen: arrows across the tiles (three // to a row) and into the side column and the bottom row. func (m *menuScreen) gridKey(a *App, k tea.KeyMsg) (screen, tea.Cmd, bool) { - var tiles, side, more []int + var tiles, side, more, nav []int for i, it := range m.items { switch it.kind { case "tile": @@ -115,6 +138,8 @@ func (m *menuScreen) gridKey(a *App, k tea.KeyMsg) (screen, tea.Cmd, bool) { side = append(side, i) case "more": more = append(more, i) + case "nav": + nav = append(nav, i) } } idx := func(list []int, i int) int { @@ -127,6 +152,10 @@ func (m *menuScreen) gridKey(a *App, k tea.KeyMsg) (screen, tea.Cmd, bool) { } cur := m.items[m.sel].kind switch { + case isKey(k, "w"): + return homeWatch(a), nil, true + case isKey(k, "L"): + return homeLadder(a), nil, true case isKey(k, "c"): if a.net != nil && a.net.welcome.Guest { return m, nil, true // guests play casual @@ -144,6 +173,8 @@ func (m *menuScreen) gridKey(a *App, k tea.KeyMsg) (screen, tea.Cmd, bool) { } case "more": m.sel = more[(idx(more, m.sel)+1)%len(more)] + case "nav": + m.sel = nav[(idx(nav, m.sel)+1)%len(nav)] } return m, nil, true case isKey(k, "left", "h"): @@ -157,10 +188,18 @@ func (m *menuScreen) gridKey(a *App, k tea.KeyMsg) (screen, tea.Cmd, bool) { m.sel = tiles[minInt(j*3+2, len(tiles)-1)] case "more": m.sel = more[(idx(more, m.sel)+len(more)-1)%len(more)] + case "nav": + m.sel = nav[(idx(nav, m.sel)+len(nav)-1)%len(nav)] } return m, nil, true case isKey(k, "down", "j"): switch cur { + case "nav": + if idx(nav, m.sel) == len(nav)-1 { + m.sel = side[0] // the leaderboard sits over the side column + } else { + m.sel = tiles[minInt(idx(nav, m.sel), len(tiles)-1)] + } case "tile": if j := idx(tiles, m.sel); j+3 < len(tiles) { m.sel = tiles[j+3] @@ -180,10 +219,14 @@ func (m *menuScreen) gridKey(a *App, k tea.KeyMsg) (screen, tea.Cmd, bool) { case "tile": if j := idx(tiles, m.sel); j >= 3 { m.sel = tiles[j-3] + } else { + m.sel = nav[minInt(j, len(nav)-1)] } case "side": if j := idx(side, m.sel); j > 0 { m.sel = side[j-1] + } else { + m.sel = nav[len(nav)-1] } case "more": m.sel = tiles[len(tiles)-1] @@ -218,7 +261,24 @@ func (m *menuScreen) gridView(a *App) string { } who += st.Dim.Render(fmt.Sprintf(" %d online", w.Online)) } - bar := st.Title.Render(GameName) + " " + st.Accent.Render("Play") + " " + st.Dim.Render("Watch Learn Leaderboard") + bar := st.Title.Render(GameName) + " " + for i, it := range m.items { + if it.kind != "nav" { + continue + } + label := " " + capital(it.label) + " " + switch { + case i == m.sel: + label = st.Key.Render(label) + case it.label == "play": + label = st.Accent.Render(label) + default: + label = st.Plain.Render(label) + } + x := x0 + render.Width(bar) + a.clickBox(len(out), x, x+render.Width(label), i) + bar += label + " " + } row(fitPad(bar, bw-render.Width(who)) + who) row(st.Border.Render(strings.Repeat("─", bw))) if b := m.banner(a); len(b) > 0 { @@ -336,7 +396,7 @@ func (m *menuScreen) gridView(a *App) string { } row(line) row("") - row(st.Dim.Render("arrows move enter select c rated or casual r rejoin q quit")) + row(st.Dim.Render("arrows move enter select c rated or casual w watch L leaderboard r rejoin q quit")) for len(out) > a.h { out = out[:a.h] } diff --git a/client/home_test.go b/client/home_test.go new file mode 100644 index 0000000..35d7c51 --- /dev/null +++ b/client/home_test.go @@ -0,0 +1,75 @@ +package client + +import ( + "context" + "io" + "log" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "rfog/server" + "rfog/server/store" +) + +// The home grid's top bar is reachable by keyboard: up from the first +// tile, along to Learn, and enter opens it. +func TestHomeTopBar(t *testing.T) { + a := testApp(t) + a.w, a.h = 160, 48 + a.applyTier() + a.screen = newMenuScreen() + a.View() + m := a.screen.(*menuScreen) + press(a, "k") + if got := m.items[m.sel].label; got != "play" { + t.Fatalf("up from the first tile: %q, want play", got) + } + press(a, "l", "l") + if got := m.items[m.sel].label; got != "learn" { + t.Fatalf("right twice: %q, want learn", got) + } + press(a, "enter") + if _, ok := a.screen.(*rosterScreen); !ok { + t.Fatalf("enter on learn: %T, want the roster", a.screen) + } +} + +// Back from an online screen goes to the home grid on a big terminal and +// to the online menu on a small one. +func TestBackGoesHome(t *testing.T) { + c := testApp(t).c + st, err := store.Open(context.Background(), "sqlite::memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + srv := server.New(c, st, server.Config{Logger: log.New(io.Discard, "", 0)}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go srv.Run(ctx) + + for _, tc := range []struct { + w, h int + home bool + }{{160, 48, true}, {80, 24, false}} { + h := newHarness(t, srv, "walker") + h.a.w, h.a.h = tc.w, tc.h + h.a.applyTier() + h.run(h.a.enter(newOnlineScreen(h.a), nil)) + if !h.pump(h.screenIs("online-menu"), 5*time.Second) { + t.Fatalf("%dx%d: not connected: %T", tc.w, tc.h, h.a.screen) + } + h.key("L") + if _, ok := h.a.screen.(*ladderScreen); !ok { + t.Fatalf("%dx%d: L opened %T, want the leaderboard", tc.w, tc.h, h.a.screen) + } + h.send(tea.KeyMsg{Type: tea.KeyEsc}) + _, home := h.a.screen.(*menuScreen) + if home != tc.home { + t.Fatalf("%dx%d: back went to %T", tc.w, tc.h, h.a.screen) + } + h.a.disconnect() + } +} diff --git a/client/layout_test.go b/client/layout_test.go index 68c21da..4d7c118 100644 --- a/client/layout_test.go +++ b/client/layout_test.go @@ -96,9 +96,10 @@ func TestMenusAreTappableEverywhere(t *testing.T) { t.Fatalf("%v: menu item %d (%s) has no tap target", sz, i, items[i].label) } } - // Two taps on the roster row open it. + // Two taps on the roster row open it (on the home grid, Learn in + // the top bar). for _, r := range a.rows { - if items[r.id].label == "roster" { + if l := items[r.id].label; l == "roster" || l == "learn" { x := sz[0] / 2 if r.x1 < 1<<30 { // part of a row (the home grid's bottom row) x = (r.x0 + r.x1) / 2 diff --git a/client/lists.go b/client/lists.go index 7b2e03b..e186e17 100644 --- a/client/lists.go +++ b/client/lists.go @@ -84,7 +84,7 @@ func (l *liveScreen) update(a *App, msg tea.Msg) (screen, tea.Cmd) { case isKey(t, "r"): _ = a.net.send(proto.TLive, nil) case isKey(t, "q", "esc"): - return newOnlineScreen(a), nil + return onlineBack(a), nil } } return l, nil @@ -220,7 +220,7 @@ func (h *historyScreen) update(a *App, msg tea.Msg) (screen, tea.Cmd) { h.waiting = h.rows[h.sel].Match _ = a.net.send(proto.TReplayGet, proto.ReplayGet{Match: h.waiting}) case isKey(t, "q", "esc"): - return newOnlineScreen(a), nil + return onlineBack(a), nil } } return h, nil @@ -335,7 +335,7 @@ func (l *ladderScreen) update(a *App, msg tea.Msg) (screen, tea.Cmd) { l.mode, l.rows = ranked[i], nil _ = a.net.send(proto.TLadder, proto.Ladder{Mode: l.mode}) case isKey(t, "q", "esc"): - return newOnlineScreen(a), nil + return onlineBack(a), nil } } return l, nil diff --git a/client/match.go b/client/match.go index 77b01be..2fbc791 100644 --- a/client/match.go +++ b/client/match.go @@ -1834,7 +1834,7 @@ func (m *matchScreen) quit(a *App, leave bool) (screen, tea.Cmd) { rm.Leave() } a.rm = nil - return newOnlineScreen(a), nil + return onlineBack(a), nil } if leave { m.src.Leave() @@ -1904,7 +1904,7 @@ func (m *matchScreen) endKey(a *App, k tea.KeyMsg) (screen, tea.Cmd) { return m.back(a), nil } if isRemote(m.src) { - return newOnlineScreen(a), nil + return onlineBack(a), nil } return newMenuScreen(), nil } diff --git a/client/online.go b/client/online.go index 3d97dc6..941b113 100644 --- a/client/online.go +++ b/client/online.go @@ -38,12 +38,34 @@ type onlineScreen struct { autoRated bool thenChallenge bool thenWatch bool // the home screen's watch: the live matches once signed in + thenLadder bool // the home screen's leaderboard, once signed in + // fromHome: opened from the home screen, so esc goes back there. + fromHome bool +} + +// onlineBack is where "back" from an online screen goes: the home screen +// when the terminal is big enough for it (quick pairing, your games and +// the leaderboard are all there), else the online menu. +func onlineBack(a *App) screen { + if a.net != nil && gridFits(a) { + return newMenuScreen() + } + return newOnlineScreen(a) +} + +// homeOr is s, or the home screen when this screen came from there and +// the terminal still has room for it. +func (o *onlineScreen) homeOr(a *App, s screen) screen { + if o.fromHome && gridFits(a) { + return newMenuScreen() + } + return s } // newOnlineQueue signs in if needed and seeks a match on one clock. func newOnlineQueue(a *App, clock string, rated bool) *onlineScreen { o := newOnlineScreen(a) - o.autoClock, o.autoRated = clock, rated + o.autoClock, o.autoRated, o.fromHome = clock, rated, true return o } @@ -228,6 +250,10 @@ func (o *onlineScreen) update(a *App, msg tea.Msg) (screen, tea.Cmd) { if o.state == "menu" && o.thenWatch { return newLobbyScreen(a), t.r.recv() } + if o.state == "menu" && o.thenLadder { + l := newLadderScreen(a, "blitz") + return l, tea.Batch(t.r.recv(), l.init(a)) + } if o.state == "menu" { return o, tea.Batch(t.r.recv(), o.auto(a)) } @@ -394,6 +420,7 @@ func (o *onlineScreen) key(a *App, k tea.KeyMsg) (screen, tea.Cmd) { if isKey(k, "esc", "q") { _ = a.net.send(proto.TCancel, nil) o.state = "menu" + return o.homeOr(a, o), nil } } return o, nil diff --git a/client/spectate.go b/client/spectate.go index 3612b7c..85daab7 100644 --- a/client/spectate.go +++ b/client/spectate.go @@ -87,7 +87,7 @@ func (l *lobbyScreen) update(a *App, msg tea.Msg) (screen, tea.Cmd) { case isKey(t, "r"): _ = a.net.send(proto.TLobby, nil) case isKey(t, "q", "esc"): - return newOnlineScreen(a), nil + return onlineBack(a), nil } } return l, nil diff --git a/cmd/rfog/main.go b/cmd/rfog/main.go index 8ae7167..8e6b9e3 100644 --- a/cmd/rfog/main.go +++ b/cmd/rfog/main.go @@ -34,6 +34,8 @@ func main() { err = runBench(args) case "status": err = runStatus(args) + case "stats": + err = runStats(args) case "asciify": err = runAsciify(args) case "version", "-v", "--version": @@ -61,6 +63,7 @@ func usage() { rfog version print the version rfog replay print a replay as a text log rfog status your ratings and matches (-json for status bars) + rfog stats server-wide counts per day, read from the store (-db -days) rfog balance bot-vs-bot win rates (-n 20 -mode 2v2 -json) rfog bench is it us or your terminal? (-n 100 -cells small) rfog asciify art pipeline diff --git a/cmd/rfog/serve.go b/cmd/rfog/serve.go index 7c36dc3..7f45a87 100644 --- a/cmd/rfog/serve.go +++ b/cmd/rfog/serve.go @@ -9,6 +9,7 @@ import ( "os/signal" "strings" "syscall" + "time" "rfog/data" "rfog/server" @@ -66,3 +67,34 @@ func env(key, def string) string { } return def } + +// runStats prints the server's activity per day from its store: accounts, +// guests, online matches, players. Live counts (online now, peak) are the +// "stats" lines in the server's log. +func runStats(args []string) error { + fs := flag.NewFlagSet("rfog stats", flag.ContinueOnError) + db := fs.String("db", env("RFOG_DB", "sqlite://rfog.db"), "store URL, as for rfog serve") + days := fs.Int("days", 14, "how many days back") + if err := fs.Parse(args); err != nil { + return err + } + ctx := context.Background() + st, err := store.Open(ctx, *db) + if err != nil { + return fmt.Errorf("store: %w", err) + } + defer st.Close() + a, err := st.Activity(ctx, time.Now().AddDate(0, 0, -(*days-1))) + if err != nil { + return err + } + fmt.Printf("accounts %d · seen today %d · this week %d · this month %d\n\n", a.Accounts, a.Seen[0], a.Seen[1], a.Seen[2]) + fmt.Printf("%-10s %8s %6s %7s %8s %6s %5s %7s\n", "day (UTC)", "accounts", "guests", "matches", "finished", "humans", "rated", "players") + for _, d := range a.Days { + fmt.Printf("%-10s %8d %6d %7d %8d %6d %5d %7d\n", d.Date, d.Accounts, d.Guests, d.Matches, d.Finished, d.Humans, d.Rated, d.Players) + } + fmt.Println("\nmatches: online only (vs bots in the web app or the terminal never reach the server).") + fmt.Println("humans: matches with two or more people. players: distinct people in that day's matches.") + fmt.Println("live counts: grep \"stats online\" in the server log.") + return nil +} diff --git a/cmd/rfog/stubs.go b/cmd/rfog/stubs.go index 67452c2..0476e10 100644 --- a/cmd/rfog/stubs.go +++ b/cmd/rfog/stubs.go @@ -18,7 +18,7 @@ func runOnline(args []string) error { return startClient(args, "online") } func startClient(args []string, start string) error { fs := flag.NewFlagSet("rfog", flag.ContinueOnError) - theme := fs.String("theme", "", "theme name (fibre mono amber green gruvbox nord catppuccin)") + theme := fs.String("theme", "", "theme: a board (fibre graphite daylight abyss nord gruvbox catppuccin tokyo-night amber green) or mono") tier := fs.String("tier", "", "render tier override (t0 t1 t2)") name := fs.String("name", "", "operator name") server := fs.String("server", "", "server address host:port (tls://host:port for TLS)") diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f0a55c7..67fecfc 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -63,6 +63,15 @@ flowchart TB send the same keys. The web app is touch and mouse first. - **Words.** They're "bots", never "AI". - **Minimal dependencies.** Ask before adding one. +- **Show it.** A change you can see (a theme, the board, a screen, the web + app) comes with screenshots; terminal changes with one at 24-bit colour and + one at 256 (`rfog -tier t1`). +- **Boards are three colours.** A board theme is its light squares, dark + squares and walls: one line in `render/boards.go` and the same line in + `BOARDS` in `web/play.js`. Everything else is derived. The figures are + dark silhouettes, so `TestBoardsReadable` holds every board to fibre's + contrast and keeps its colours apart at 256 colours; `web/boards_test.go` + keeps the two lists equal. ## Changing balance diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 1dd9acb..b9c2490 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -53,6 +53,11 @@ tutorial match with a coach. ./rfog bots # straight into a match against bots ``` +On a big terminal the home screen has the web app's top bar: **Watch** +(`w`), **Learn** (the commanders) and **Leaderboard** (`L`); up from the +clocks reaches it. Back (`esc`) from an online screen returns to this home +screen; on a small terminal, to the online menu. + New here? The terminal's **how to play** (tutorial) is a guided match, and the web app's **Learn** page explains a turn in a minute. @@ -135,7 +140,7 @@ are never required. ## Commanders Every player gets a commander plus a lineman, a ranged unit, a runner and a -medic. In team modes each player gets fewer units. +medic. (In the experimental team modes each player gets fewer units.)
HASK, Breaker: melee frontline @@ -284,12 +289,13 @@ toward your rating in that clock's category, using Glicko-2 (the system Lichess uses). Casual games welcome guests, and bots fill empty seats after a wait. While RFoG is in alpha, ratings may be reset. -Pick a size from 1v1 to 5v5 (team modes are in the terminal for now). Team -modes open with a ban, then picks go in snake order, and teammates share -vision and score. In the online menu, `a` opens your account -page (ranked needs an account), `L` shows the ladder, `H` your finished -matches and replays, and `w` lets you watch a live match (ranked matches -are shown a turn behind). +RFoG is a 1v1 game. Team modes (2v2 to 5v5) are an experiment in the +terminal client only: unbalanced, and likely to change. They open with a +ban, then picks go in snake order, and teammates share vision and score. + +In the online menu, `a` opens your account page (ranked needs an account), +`L` shows the ladder, `H` your finished matches and replays, and `w` lets +you watch a live match (ranked matches are shown a turn behind). ### Resume anywhere diff --git a/docs/screenshots/terminal-home.png b/docs/screenshots/terminal-home.png deleted file mode 100644 index 64b0c04..0000000 Binary files a/docs/screenshots/terminal-home.png and /dev/null differ diff --git a/docs/screenshots/terminal-match.png b/docs/screenshots/terminal-match.png deleted file mode 100644 index 244410f..0000000 Binary files a/docs/screenshots/terminal-match.png and /dev/null differ diff --git a/docs/screenshots/terminal-roster.png b/docs/screenshots/terminal-roster.png deleted file mode 100644 index 46bebe3..0000000 Binary files a/docs/screenshots/terminal-roster.png and /dev/null differ diff --git a/docs/screenshots/web-phone-home.jpg b/docs/screenshots/web-phone-home.jpg deleted file mode 100644 index 6c29a88..0000000 Binary files a/docs/screenshots/web-phone-home.jpg and /dev/null differ diff --git a/docs/screenshots/web-phone-match.jpg b/docs/screenshots/web-phone-match.jpg deleted file mode 100644 index ca6434c..0000000 Binary files a/docs/screenshots/web-phone-match.jpg and /dev/null differ diff --git a/docs/screenshots/web-phone-replay.jpg b/docs/screenshots/web-phone-replay.jpg deleted file mode 100644 index e710ff3..0000000 Binary files a/docs/screenshots/web-phone-replay.jpg and /dev/null differ diff --git a/render/boards.go b/render/boards.go new file mode 100644 index 0000000..2bfd045 --- /dev/null +++ b/render/boards.go @@ -0,0 +1,127 @@ +package render + +import ( + "fmt" + "math" + "strconv" +) + +// Board is a board theme: the light squares, the dark squares and the +// walls. Both clients offer the same boards in the same order (the web +// app's copy is BOARDS in web/play.js; web/boards_test.go keeps the two +// equal), and every other colour a terminal theme needs is derived from +// these three, so a board looks the same wherever it is played. +// +// The figures are dark silhouettes, so a board's squares must be light +// enough to show them; TestBoardsReadable sets the bar. +type Board struct { + Name, Light, Dark, Wall string +} + +// Boards are the board themes, fibre (the default) first. +var Boards = []Board{ + {"fibre", "#52778a", "#3d5a6b", "#0d161c"}, + {"graphite", "#6e6e6c", "#555553", "#1a1a19"}, + {"daylight", "#9aa3ab", "#6c757d", "#22262a"}, + {"abyss", "#5d6976", "#4a5561", "#0b0d10"}, + {"nord", "#81a1c1", "#5e81ac", "#2e3440"}, + {"gruvbox", "#a89984", "#7c6f64", "#282828"}, + {"catppuccin", "#9399b2", "#6c7086", "#11111b"}, + {"tokyo-night", "#737aa2", "#565f89", "#16161e"}, + {"amber", "#b08a3a", "#7d5f22", "#1f1606"}, + {"green", "#3f8a46", "#2f6b35", "#0a1f0c"}, +} + +// chrome is everything that is not the board: text, the two sides, the +// signal colour. It is fibre's on every board, as in the web app, so the +// sides and the interface read the same whichever board is chosen. +var chrome = Theme{ + Fg: "#cdd5dc", Dim: "#76808a", Accent: "#4fd3e8", TeamA: "#4fd3e8", TeamB: "#ff7043", + Danger: "#ff5a4a", Warn: "#e3c05c", Good: "#7ed98a", Objective: "#e6f0f5", Smoke: "#aab4bc", + Cover: "#c9a13a", Cursor: "#1b8ea3", Border: "#3a4552", +} + +// BoardTheme is the terminal theme for a board: fibre's chrome, and the +// board's colours worked out as the web app draws them (raised ground +// lighter, unseen squares at 45% brightness, held objectives tinted by +// side). +func BoardTheme(b Board) Theme { + t := chrome + t.Name = b.Name + t.Light, t.Board, t.Wall = b.Light, b.Dark, b.Wall + for z := range t.Ground { + t.Ground[z] = mix(b.Light, "#ffffff", 0.18+0.12*float64(z)) + } + t.Fog = scale(mix(b.Light, b.Dark, 0.5), 0.45) + t.WallTop = scale(mix(grey(b.Dark), b.Dark, 0.35), 1.2) + t.ObjBg = mix(b.Light, "#ffffff", 0.3) + t.ObjA = mix(b.Dark, chrome.TeamA, 0.35) + t.ObjB = mix(b.Dark, chrome.TeamB, 0.45) + t.ObjC = mix(b.Dark, chrome.Warn, 0.45) + t.LastBg = mix(b.Light, chrome.Accent, 0.2) + t.LedgeBg = scale(b.Dark, 0.65) + t.CastBg = scale(b.Dark, 0.85) + t.Highlight = mix(scale(b.Dark, 0.7), chrome.Accent, 0.2) + return t +} + +// mix is a + (b-a)*f per channel, as hex. +func mix(a, b string, f float64) string { + x, y := hexRGB(a), hexRGB(b) + var o [3]float64 + for i := range o { + o[i] = x[i] + (y[i]-x[i])*f + } + return rgbHex(o) +} + +// grey is a colour's luma as a grey. +func grey(a string) string { + x := hexRGB(a) + y := 0.3*x[0] + 0.59*x[1] + 0.11*x[2] + return rgbHex([3]float64{y, y, y}) +} + +// scale multiplies every channel (CSS brightness()). +func scale(a string, f float64) string { + x := hexRGB(a) + return rgbHex([3]float64{x[0] * f, x[1] * f, x[2] * f}) +} + +func hexRGB(h string) [3]float64 { + var o [3]float64 + if len(h) != 7 || h[0] != '#' { + return o + } + for i := range o { + v, _ := strconv.ParseUint(h[1+2*i:3+2*i], 16, 8) + o[i] = float64(v) + } + return o +} + +func rgbHex(c [3]float64) string { + ch := func(v float64) int { return int(math.Max(0, math.Min(255, math.Round(v)))) } + return fmt.Sprintf("#%02x%02x%02x", ch(c[0]), ch(c[1]), ch(c[2])) +} + +// Contrast is the WCAG contrast ratio between two hex colours (1 to 21). +func Contrast(a, b string) float64 { + la, lb := luminance(a), luminance(b) + if la < lb { + la, lb = lb, la + } + return (la + 0.05) / (lb + 0.05) +} + +func luminance(h string) float64 { + c := hexRGB(h) + lin := func(v float64) float64 { + v /= 255 + if v <= 0.04045 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) + } + return 0.2126*lin(c[0]) + 0.7152*lin(c[1]) + 0.0722*lin(c[2]) +} diff --git a/render/boards_test.go b/render/boards_test.go new file mode 100644 index 0000000..6783d2e --- /dev/null +++ b/render/boards_test.go @@ -0,0 +1,50 @@ +package render + +import "testing" + +// figureDark is the figures' dark mass (assets/units.go, 'd'): what a +// square has to stand out from for a unit to read. +const figureDark = "#161b21" + +// Every board must show the figures, keep its checker, set walls apart, +// and keep its board colours distinct in a 256-colour terminal too. +func TestBoardsReadable(t *testing.T) { + for _, b := range Boards { + if c := Contrast(b.Dark, figureDark); c < 2.2 { + t.Errorf("%s: dark squares %.2f:1 against the figures, want >= 2.2", b.Name, c) + } + if c := Contrast(b.Light, b.Dark); c < 1.25 || c > 2 { + t.Errorf("%s: light/dark %.2f:1, want 1.25 to 2", b.Name, c) + } + if c := Contrast(b.Dark, b.Wall); c < 2 { + t.Errorf("%s: walls %.2f:1 against dark squares, want >= 2", b.Name, c) + } + for _, th := range []Theme{GetTheme(b.Name), Theme256(GetTheme(b.Name))} { + named := map[string]string{"light": th.Light, "dark": th.Board, "fog": th.Fog, "wall": th.Wall, + "wall top": th.WallTop, "objective": th.ObjBg, "held A": th.ObjA, "held B": th.ObjB, "contested": th.ObjC} + seen := map[string]string{} + for name, c := range named { + if other, ok := seen[c]; ok { + t.Errorf("%s (%s): %s and %s are both %s", b.Name, th.Light, name, other, c) + } + seen[c] = name + } + } + } +} + +// Every board has a terminal theme and the names line up. +func TestBoardThemes(t *testing.T) { + names := ThemeNames() + for i, b := range Boards { + if names[i] != b.Name { + t.Fatalf("theme %d is %q, want board %q", i, names[i], b.Name) + } + } + if names[len(names)-1] != "mono" { + t.Fatalf("mono should be last: %v", names) + } + if GetTheme("no such theme").Name != "fibre" { + t.Fatal("unknown themes should fall back to fibre") + } +} diff --git a/render/color256.go b/render/color256.go index 41c41f3..80f7d07 100644 --- a/render/color256.go +++ b/render/color256.go @@ -17,9 +17,18 @@ func Theme256(t Theme) Theme { if t.Name == "fibre" { return fibre256 } + // The board's own colours must stay apart; a later one that would round + // onto an earlier one takes the next nearest. + used := map[string]bool{} + for _, p := range []*string{&t.Light, &t.Board, &t.Fog, &t.Wall, &t.WallTop, &t.ObjBg, &t.ObjA, &t.ObjB, &t.ObjC} { + if *p != "" { + *p = palette.SnapDistinct(*p, used) + used[*p] = true + } + } for _, p := range []*string{&t.Fg, &t.Dim, &t.Accent, &t.TeamA, &t.TeamB, &t.Danger, &t.Warn, &t.Good, &t.Objective, - &t.Smoke, &t.Wall, &t.Cover, &t.Fog, &t.Cursor, &t.Highlight, &t.Border, &t.Board, &t.Light, &t.WallTop, - &t.ObjBg, &t.ObjA, &t.ObjB, &t.ObjC, &t.LastBg, &t.LedgeBg, &t.CastBg, &t.Ground[0], &t.Ground[1], &t.Ground[2], &t.Ground[3]} { + &t.Smoke, &t.Cover, &t.Cursor, &t.Highlight, &t.Border, &t.LastBg, &t.LedgeBg, &t.CastBg, + &t.Ground[0], &t.Ground[1], &t.Ground[2], &t.Ground[3]} { *p = palette.Snap256(*p) } return t diff --git a/render/palette/palette.go b/render/palette/palette.go index 186a25d..678f7d1 100644 --- a/render/palette/palette.go +++ b/render/palette/palette.go @@ -33,6 +33,33 @@ func Snap256(hex string) string { return xtermHex[Index256(hex)-16] } +// SnapDistinct is the xterm-256 colour nearest hex that is not in used, as +// "#rrggbb": for a set of colours that must stay apart (a board's squares, +// walls and objectives) when two of them would round to the same one. +func SnapDistinct(hex string, used map[string]bool) string { + r, g, b, ok := parseHex(hex) + if !ok { + return hex + } + if s := Snap256(hex); !used[s] { + return s + } + want := lab(r, g, b) + snapMu.Lock() + defer snapMu.Unlock() + best, bestD := "", math.MaxFloat64 + for i, c := range xterm { + if used[xtermHex[i]] { + continue + } + d := (c[0]-want[0])*(c[0]-want[0]) + (c[1]-want[1])*(c[1]-want[1]) + (c[2]-want[2])*(c[2]-want[2]) + if d < bestD { + best, bestD = xtermHex[i], d + } + } + return best +} + var ( snapMu sync.Mutex snapCache = map[string]int{} diff --git a/render/theme.go b/render/theme.go index e7533bd..21e4221 100644 --- a/render/theme.go +++ b/render/theme.go @@ -46,72 +46,46 @@ type Theme struct { ObjC, LastBg, LedgeBg, CastBg string } -var themes = map[string]Theme{ - "mono": {Name: "mono", Mono: true}, - // fibre is the graphical client's palette: cold slate squares, cyan - // for one side and hazard orange for the other, walls as dark blocks. - "fibre": { - Name: "fibre", Fg: "#cdd5dc", Dim: "#76808a", Accent: "#4fd3e8", TeamA: "#4fd3e8", TeamB: "#ff7043", - Danger: "#ff5a4a", Warn: "#e3c05c", Good: "#7ed98a", Objective: "#e6f0f5", Smoke: "#aab4bc", Wall: "#141c23", - Cover: "#c9a13a", Ground: [4]string{"#7d93a3", "#8ea5b5", "#a0b8c8", "#b4ccdc"}, Fog: "#28333d", - Cursor: "#1b8ea3", Highlight: "#2d6676", Border: "#3a4552", Board: "#44596a", Light: "#5a7282", WallTop: "#4a5966", - ObjBg: "#7a92a3", ObjA: "#3d8291", ObjB: "#93614d", - ObjC: "#7d6c3c", LastBg: "#4f8597", LedgeBg: "#2d3c47", CastBg: "#3a4c59", - }, - "amber": { - Name: "amber", Fg: "#ffb000", Dim: "#7a5200", Accent: "#ffd75f", TeamA: "#ffd75f", TeamB: "#ff8700", - Danger: "#ff5f00", Warn: "#ffaf00", Good: "#d7af00", Objective: "#ffff5f", Smoke: "#875f00", Wall: "#5f3f00", - Cover: "#af8700", Ground: [4]string{"#5f4a1a", "#7a5f22", "#9a782c", "#b8913a"}, Fog: "#3a2a08", - Cursor: "#875f00", Highlight: "#3a2a00", Border: "#875f00", Board: "#2a1e06", - }, - "green": { - Name: "green", Fg: "#33ff33", Dim: "#1a7a1a", Accent: "#aaffaa", TeamA: "#aaffaa", TeamB: "#33cc33", - Danger: "#ffff55", Warn: "#88ff44", Good: "#33ff33", Objective: "#ffffaa", Smoke: "#227722", Wall: "#0f3f0f", - Cover: "#22aa22", Ground: [4]string{"#0e3a0e", "#155215", "#1d6a1d", "#268226"}, Fog: "#071f07", - Cursor: "#1f7f1f", Highlight: "#0f3f0f", Border: "#1f7f1f", Board: "#0c240c", - }, - "gruvbox": { - Name: "gruvbox", Fg: "#ebdbb2", Dim: "#928374", Accent: "#fabd2f", TeamA: "#83a598", TeamB: "#fb4934", - Danger: "#fb4934", Warn: "#fe8019", Good: "#b8bb26", Objective: "#fabd2f", Smoke: "#a89984", Wall: "#3c3836", - Cover: "#98971a", Ground: [4]string{"#3c3836", "#504945", "#665c54", "#7c6f64"}, Fog: "#282828", - Cursor: "#d79921", Highlight: "#3c3836", Border: "#665c54", Board: "#32302f", - }, - "nord": { - Name: "nord", Fg: "#d8dee9", Dim: "#4c566a", Accent: "#88c0d0", TeamA: "#81a1c1", TeamB: "#bf616a", - Danger: "#bf616a", Warn: "#d08770", Good: "#a3be8c", Objective: "#ebcb8b", Smoke: "#7b88a1", Wall: "#3b4252", - Cover: "#8fbcbb", Ground: [4]string{"#2e3440", "#3b4252", "#434c5e", "#4c566a"}, Fog: "#242933", - Cursor: "#5e81ac", Highlight: "#3b4252", Border: "#4c566a", Board: "#272e3c", - }, - "catppuccin": { - Name: "catppuccin", Fg: "#cdd6f4", Dim: "#6c7086", Accent: "#cba6f7", TeamA: "#89b4fa", TeamB: "#f38ba8", - Danger: "#f38ba8", Warn: "#fab387", Good: "#a6e3a1", Objective: "#f9e2af", Smoke: "#9399b2", Wall: "#313244", - Cover: "#94e2d5", Ground: [4]string{"#1e1e2e", "#313244", "#45475a", "#585b70"}, Fog: "#181825", - Cursor: "#b4befe", Highlight: "#313244", Border: "#585b70", Board: "#1c1c2b", - }, - "tokyo-night": { - Name: "tokyo-night", Fg: "#c0caf5", Dim: "#565f89", Accent: "#7aa2f7", TeamA: "#7dcfff", TeamB: "#f7768e", - Danger: "#f7768e", Warn: "#e0af68", Good: "#9ece6a", Objective: "#bb9af7", Smoke: "#737aa2", Wall: "#24283b", - Cover: "#e0af68", Ground: [4]string{"#16161e", "#1a1b26", "#24283b", "#414868"}, Fog: "#16161e", - Cursor: "#33467c", Highlight: "#283457", Border: "#3b4261", Board: "#1f2335", - }, -} +// themes are mono (no colour at all, for any terminal) and one per board. +var themes = func() map[string]Theme { + m := map[string]Theme{"mono": {Name: "mono", Mono: true}} + for _, b := range Boards { + m[b.Name] = BoardTheme(b) + } + return m +}() -// ThemeNames lists the built-in themes. +// ThemeNames lists the themes: the boards in their order, then mono. func ThemeNames() []string { var out []string + for _, b := range Boards { + out = append(out, b.Name) + } + var extra []string for k := range themes { - out = append(out, k) + if _, ok := boardIndex(k); !ok && k != "mono" { + extra = append(extra, k) + } + } + sort.Strings(extra) + return append(append(out, extra...), "mono") +} + +func boardIndex(name string) (int, bool) { + for i, b := range Boards { + if b.Name == name { + return i, true + } } - sort.Strings(out) - return out + return 0, false } -// GetTheme returns a built-in theme, falling back to gruvbox. +// GetTheme returns a theme by name, falling back to fibre. func GetTheme(name string) Theme { if t, ok := themes[name]; ok { return t } - return themes["gruvbox"] + return themes["fibre"] } // RegisterTheme adds or replaces a theme (from the user's theme.toml). diff --git a/server/server.go b/server/server.go index 1246471..3679474 100644 --- a/server/server.go +++ b/server/server.go @@ -108,7 +108,9 @@ type Config struct { // it widens by RatingWiden every second of waiting. RatingWindow float64 RatingWiden float64 - Logger *log.Logger + // StatsEvery is how often the live counts are logged (see stats.go). + StatsEvery time.Duration + Logger *log.Logger } func (c *Config) defaults() { @@ -133,6 +135,9 @@ func (c *Config) defaults() { if c.RatingWiden == 0 { c.RatingWiden = 10 } + if c.StatsEvery == 0 { + c.StatsEvery = 10 * time.Minute + } if c.Logger == nil { c.Logger = log.Default() } @@ -155,6 +160,8 @@ type Server struct { guards map[string]*guard // challenges are open challenges by code (see challenge.go). challenges map[string]*challenge + // peak is the most sessions online since the last stats line. + peak int } // New creates a server. Call Run to start the matchmaker. @@ -172,6 +179,8 @@ func (s *Server) Run(ctx context.Context) error { } t := time.NewTicker(time.Second) defer t.Stop() + stats := time.NewTicker(s.cfg.StatsEvery) + defer stats.Stop() for { select { case <-ctx.Done(): @@ -189,6 +198,9 @@ func (s *Server) Run(ctx context.Context) error { case <-t.C: s.lobby.tick() s.expireChallenges() + s.samplePeak() + case <-stats.C: + s.logStats() } } } @@ -207,8 +219,13 @@ func (s *Server) Online() int { } // Serve runs one client connection to completion. -func (s *Server) Serve(rw io.ReadWriteCloser) { +func (s *Server) Serve(rw io.ReadWriteCloser) { s.serve(rw, "") } + +// serve runs a connection; via names the transport when the client's +// Hello cannot tell it (SSH runs the terminal client in-process). +func (s *Server) serve(rw io.ReadWriteCloser, via string) { conn := newConn(rw) + conn.kind = via defer conn.Close() sess, err := s.handshake(conn) if err != nil { @@ -236,7 +253,7 @@ func (s *Server) Serve(rw io.ReadWriteCloser) { // in-process pipe (used by SSH sessions). func (s *Server) DialInternal() net.Conn { a, b := net.Pipe() - go s.Serve(b) + go s.serve(b, "ssh") return a } @@ -255,6 +272,9 @@ func (s *Server) handshake(conn *Conn) (*Session, error) { if h.Version != proto.Version { return nil, fmt.Errorf("client protocol %d, server %d", h.Version, proto.Version) } + if conn.kind == "" { + conn.kind = clientKind(h.Client) + } f, err = conn.Recv() if err != nil { return nil, err @@ -412,6 +432,7 @@ func (s *Server) removeMatch(id string) { // writer goroutine so that match logic never blocks on a slow client; a // client that falls too far behind is dropped. type Conn struct { + kind string // web, terminal, ssh or other: counted in the stats line rw io.ReadWriteCloser out chan []byte closed chan struct{} diff --git a/server/stats.go b/server/stats.go new file mode 100644 index 0000000..d571594 --- /dev/null +++ b/server/stats.go @@ -0,0 +1,78 @@ +package server + +// Live counts for the operator: one aggregate line in the log every +// Config.StatsEvery. Nothing about any player is kept or written; the +// history side is `rfog stats`, which reads the store (store.Activity). + +// Live is a snapshot of who is connected and how. +type Live struct { + Online int // connected sessions + Peak int // most online since the last stats line + Web int // the web app + Terminal int // the terminal client, dialling in (TCP or WebSocket) + SSH int // the terminal client run over ssh + Matches int // matches in progress on the server +} + +// clientKind sorts a Hello.Client name into the stats line's buckets. +func clientKind(client string) string { + switch client { + case "web": + return "web" + case "rfog-tui": + return "terminal" + } + return "other" +} + +// Live counts the connected sessions by client and the matches running. +func (s *Server) Live() Live { + s.mu.Lock() + sessions := make([]*Session, 0, len(s.sessions)) + for _, se := range s.sessions { + sessions = append(sessions, se) + } + l := Live{Matches: len(s.matches), Peak: s.peak} + s.mu.Unlock() + for _, se := range sessions { + se.mu.Lock() + c := se.conn + se.mu.Unlock() + if c == nil { + continue + } + l.Online++ + switch c.kind { + case "web": + l.Web++ + case "terminal": + l.Terminal++ + case "ssh": + l.SSH++ + } + } + if l.Online > l.Peak { + l.Peak = l.Online + } + return l +} + +// samplePeak runs every second so the stats line's peak sees short visits. +func (s *Server) samplePeak() { + n := s.Online() + s.mu.Lock() + if n > s.peak { + s.peak = n + } + s.mu.Unlock() +} + +// logStats writes the stats line and starts a new peak window. +func (s *Server) logStats() { + l := s.Live() + s.mu.Lock() + s.peak = l.Online + s.mu.Unlock() + s.log.Printf("stats online=%d peak=%d web=%d terminal=%d ssh=%d matches=%d", + l.Online, l.Peak, l.Web, l.Terminal, l.SSH, l.Matches) +} diff --git a/server/stats_test.go b/server/stats_test.go new file mode 100644 index 0000000..c89b5b6 --- /dev/null +++ b/server/stats_test.go @@ -0,0 +1,57 @@ +package server + +import ( + "bytes" + "log" + "net" + "strings" + "testing" + "time" + + "rfog/proto" +) + +// The stats line counts sessions by client: the web app by its Hello, the +// terminal client over ssh by the listener, and keeps the window's peak. +func TestLiveStats(t *testing.T) { + s, stop := newTestServer(t, Config{}) + defer stop() + + a, b := net.Pipe() + go s.Serve(b) + web := &tclient{t: t, c: a, in: make(chan proto.Frame, 64), name: "w"} + go func() { + for { + f, err := proto.Decode(a) + if err != nil { + close(web.in) + return + } + web.in <- f + } + }() + web.send(proto.THello, proto.Hello{Version: proto.Version, Client: "web"}) + web.send(proto.TAuth, proto.Auth{Name: "w"}) + web.expect(proto.TWelcome) + ssh := dial(t, s, "s", "") + + l := s.Live() + if l.Online != 2 || l.Web != 1 || l.SSH != 1 || l.Terminal != 0 || l.Peak != 2 { + t.Fatalf("live: %+v", l) + } + s.samplePeak() + ssh.c.Close() + deadline := time.Now().Add(2 * time.Second) + for s.Online() != 1 && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + var buf bytes.Buffer + s.log = log.New(&buf, "", 0) + s.logStats() + if got := buf.String(); !strings.Contains(got, "stats online=1 peak=2 web=1 terminal=0 ssh=0") { + t.Fatalf("line: %q", got) + } + if s.Live().Peak != 1 { + t.Fatalf("peak not reset: %+v", s.Live()) + } +} diff --git a/server/store/sql.go b/server/store/sql.go index f7b9696..b35f41b 100644 --- a/server/store/sql.go +++ b/server/store/sql.go @@ -458,3 +458,123 @@ func (s *sqlStore) History(ctx context.Context, playerID string, limit int) ([]M } return out, nil } + +// ---- activity ------------------------------------------------------------- + +func (s *sqlStore) Activity(ctx context.Context, since time.Time) (Activity, error) { + var a Activity + since = since.UTC().Truncate(24 * time.Hour) + index := map[string]int{} + for d := since; !d.After(time.Now().UTC()); d = d.Add(24 * time.Hour) { + index[d.Format("2006-01-02")] = len(a.Days) + a.Days = append(a.Days, Day{Date: d.Format("2006-01-02")}) + } + day := func(unix int64) *Day { + if i, ok := index[time.Unix(unix, 0).UTC().Format("2006-01-02")]; ok { + return &a.Days[i] + } + return nil + } + + rows, err := s.db.QueryContext(ctx, s.q(`SELECT created, guest FROM players WHERE created >= ?`), since.Unix()) + if err != nil { + return a, err + } + for rows.Next() { + var created int64 + var guest int + if err := rows.Scan(&created, &guest); err != nil { + rows.Close() + return a, err + } + if d := day(created); d != nil { + if guest != 0 { + d.Guests++ + } else { + d.Accounts++ + } + } + } + rows.Close() + if err := rows.Err(); err != nil { + return a, err + } + + type match struct { + started int64 + finished bool + humans int + rated bool + players []string + } + matches := map[string]*match{} + rows, err = s.db.QueryContext(ctx, s.q(`SELECT m.id, m.started, m.ended, mp.player_id, mp.bot, mp.rating_before + FROM matches m JOIN match_players mp ON mp.match_id = m.id WHERE m.started >= ?`), since.Unix()) + if err != nil { + return a, err + } + for rows.Next() { + var id, player string + var started int64 + var ended sql.NullInt64 + var bot int + var before sql.NullFloat64 + if err := rows.Scan(&id, &started, &ended, &player, &bot, &before); err != nil { + rows.Close() + return a, err + } + m := matches[id] + if m == nil { + m = &match{started: started, finished: ended.Valid} + matches[id] = m + } + if bot == 0 { + m.humans++ + m.players = append(m.players, player) + } + if before.Valid { + m.rated = true + } + } + rows.Close() + if err := rows.Err(); err != nil { + return a, err + } + players := map[string]map[string]bool{} + for _, m := range matches { + d := day(m.started) + if d == nil { + continue + } + d.Matches++ + if m.finished { + d.Finished++ + } + if m.humans >= 2 { + d.Humans++ + } + if m.rated { + d.Rated++ + } + if players[d.Date] == nil { + players[d.Date] = map[string]bool{} + } + for _, p := range m.players { + players[d.Date][p] = true + } + } + for i := range a.Days { + a.Days[i].Players = len(players[a.Days[i].Date]) + } + + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM players WHERE guest = 0`).Scan(&a.Accounts); err != nil { + return a, err + } + for i, days := range []int{1, 7, 30} { + cut := time.Now().Add(-time.Duration(days) * 24 * time.Hour).Unix() + if err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM players WHERE last_seen >= ?`), cut).Scan(&a.Seen[i]); err != nil { + return a, err + } + } + return a, nil +} diff --git a/server/store/store.go b/server/store/store.go index 5e730d4..1cfa470 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -81,6 +81,26 @@ type MatchPlayer struct { RatingAfter *float64 } +// Activity is the server's history in aggregate. +type Activity struct { + Days []Day // oldest first, one per UTC day from since to today + Accounts int // named accounts now + Seen [3]int // players seen in the last 1, 7 and 30 days +} + +// Day is one UTC day. Players created as guests and later registered count +// as accounts on the day they first came. +type Day struct { + Date string // YYYY-MM-DD + Accounts int // accounts created + Guests int // guests created (and still guests) + Matches int // online matches started + Finished int // of those, finished + Humans int // of those, with two or more human players + Rated int // of those, rated + Players int // distinct human players in those matches +} + type Snapshot struct { MatchID string Turn int @@ -135,6 +155,9 @@ type Store interface { // LiveMatches returns snapshots of matches that never ended (crash recovery). LiveMatches(ctx context.Context) ([]Snapshot, []Match, error) History(ctx context.Context, playerID string, limit int) ([]Match, error) + // Activity counts accounts and online matches per UTC day since a + // time, for the operator (rfog stats). Aggregates only. + Activity(ctx context.Context, since time.Time) (Activity, error) Close() error } diff --git a/server/store/store_test.go b/server/store/store_test.go index 9e3c76d..e9775c2 100644 --- a/server/store/store_test.go +++ b/server/store/store_test.go @@ -210,3 +210,47 @@ func TestPostgres(t *testing.T) { defer s.Close() testAccountsRatingsReplay(t, ctx, s) } + +func TestActivity(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, "sqlite::memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + g, _ := s.CreateGuest(ctx, "g") + p, err := s.CreateAccount(ctx, "acct", "x") + if err != nil { + t.Fatal(err) + } + r := 1500.0 + human := Match{ID: "m1", Mode: "1v1", Map: "relay", TimeCtl: "blitz", Started: time.Now(), + Players: []MatchPlayer{{PlayerID: g.ID, Slot: 0, Hero: "hask", RatingBefore: &r}, {PlayerID: p.ID, Slot: 1, Team: 1, Hero: "wren", RatingBefore: &r}}} + bots := Match{ID: "m2", Mode: "1v1", Map: "relay", TimeCtl: "blitz", Started: time.Now(), + Players: []MatchPlayer{{PlayerID: g.ID, Slot: 0, Hero: "hask"}, {PlayerID: "bot", Slot: 1, Team: 1, Hero: "wren", Bot: true}}} + old := Match{ID: "m3", Mode: "1v1", Map: "relay", TimeCtl: "blitz", Started: time.Now().AddDate(0, 0, -40), + Players: []MatchPlayer{{PlayerID: g.ID, Slot: 0, Hero: "hask"}}} + for _, m := range []Match{human, bots, old} { + if err := s.CreateMatch(ctx, m); err != nil { + t.Fatal(err) + } + } + if err := s.EndMatch(ctx, "m1", 0, "score", nil); err != nil { + t.Fatal(err) + } + a, err := s.Activity(ctx, time.Now().AddDate(0, 0, -6)) + if err != nil { + t.Fatal(err) + } + if len(a.Days) != 7 { + t.Fatalf("days: %d", len(a.Days)) + } + d := a.Days[len(a.Days)-1] + want := Day{Date: time.Now().UTC().Format("2006-01-02"), Accounts: 1, Guests: 1, Matches: 2, Finished: 1, Humans: 1, Rated: 1, Players: 2} + if d != want { + t.Fatalf("today: %+v, want %+v", d, want) + } + if a.Accounts != 1 || a.Seen != [3]int{2, 2, 2} { + t.Fatalf("totals: %+v", a) + } +} diff --git a/web/boards_test.go b/web/boards_test.go new file mode 100644 index 0000000..390efb3 --- /dev/null +++ b/web/boards_test.go @@ -0,0 +1,45 @@ +package web + +import ( + "os" + "regexp" + "testing" + + "rfog/render" +) + +// The web app and the terminal client offer the same boards, in the same +// order, in the same colours; the page's default (:root in play.css) is +// the first of them. +func TestBoardsMatchTerminal(t *testing.T) { + js, err := os.ReadFile("play.js") + if err != nil { + t.Fatal(err) + } + row := regexp.MustCompile(`\['([a-z-]+)', '(#[0-9a-f]{6})', '(#[0-9a-f]{6})', '(#[0-9a-f]{6})'\]`) + block := regexp.MustCompile(`(?s)var BOARDS = \[(.*?)\];`).FindSubmatch(js) + if block == nil { + t.Fatal("no BOARDS in play.js") + } + var web []render.Board + for _, m := range row.FindAllStringSubmatch(string(block[1]), -1) { + web = append(web, render.Board{Name: m[1], Light: m[2], Dark: m[3], Wall: m[4]}) + } + if len(web) != len(render.Boards) { + t.Fatalf("play.js has %d boards, render.Boards %d", len(web), len(render.Boards)) + } + for i, b := range render.Boards { + if web[i] != b { + t.Errorf("board %d: play.js %+v, render.Boards %+v", i, web[i], b) + } + } + css, err := os.ReadFile("play.css") + if err != nil { + t.Fatal(err) + } + f := render.Boards[0] + want := "--light: " + f.Light + "; --dark: " + f.Dark + "; --wall: " + f.Wall + ";" + if !regexp.MustCompile(regexp.QuoteMeta(want)).Match(css) { + t.Errorf("play.css :root should carry %s (%s)", want, f.Name) + } +} diff --git a/web/index.html b/web/index.html index b1b639c..2347f14 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,17 @@ somewhere else (GitHub Pages): "play.example.org". Empty = the host that served the page. --> - + + + + + + + + + + + diff --git a/web/og.png b/web/og.png new file mode 100644 index 0000000..3062c36 Binary files /dev/null and b/web/og.png differ diff --git a/web/play.css b/web/play.css index 93e120e..95f515c 100644 --- a/web/play.css +++ b/web/play.css @@ -346,10 +346,8 @@ h1.wordmark::after { content: ""; display: block; height: 2px; width: 70%; margi } :root[data-motion="off"] .glitch, :root[data-motion="off"] .zone i, :root[data-motion="off"] .acquire i { animation: none; } -/* board themes: same meaning, different light. fibre is the default. */ -:root[data-board="graphite"] { --light: #6e6e6c; --dark: #555553; --wall: #1a1a19; } -:root[data-board="daylight"] { --light: #9aa3ab; --dark: #6c757d; --wall: #22262a; } -:root[data-board="abyss"] { --light: #383d43; --dark: #2a2e33; --wall: #0a0b0c; } +/* board themes: same meaning, different light. fibre (above) is the + default; the others are BOARDS in play.js, set on :root when chosen. */ /* the scoring explainer in the sheet */ .rules { grid-column: span 2; font-size: 13px; line-height: 1.5; color: var(--fg); padding: 0 4px; } @@ -361,6 +359,10 @@ h1.wordmark::after { content: ""; display: block; height: 2px; width: 70%; margi .setting { grid-column: span 2; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 6px 2px; font-size: 13px; color: var(--dim); } .setting .seg button { padding: 7px 10px; font-size: 12px; } +.setting .swatchseg { flex-wrap: wrap; justify-content: flex-end; max-width: 210px; } +#sheet .setting .swatchseg button { padding: 4px; min-height: 0; background: transparent; } +.setting .swatchseg i { display: block; width: 18px; height: 18px; border-radius: 3px; } +.setting .swatchseg button.on i { box-shadow: 0 0 0 2px var(--signal); } /* board coordinates, as a chess board has them */ .coord { position: absolute; font-size: 10px; font-weight: 600; color: rgba(0, 0, 0, 0.45); pointer-events: none; z-index: 1; } @@ -510,7 +512,7 @@ h1.wordmark::after { content: ""; display: block; height: 2px; width: 70%; margi .prow > div:first-child { display: flex; flex-direction: column; gap: 2px; } .prow small { color: var(--dim); font-size: 12px; } .prow button { padding: 7px 12px; font-size: 13px; } -.swatches { display: flex; gap: 8px; } +.swatches { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } .sw { display: flex; flex-direction: column; align-items: center; gap: 4px; background: transparent !important; padding: 4px !important; } .sw i { display: block; width: 44px; height: 44px; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line); } .sw.on i { box-shadow: 0 0 0 2px var(--signal); } diff --git a/web/play.js b/web/play.js index 670dc6e..364a181 100644 --- a/web/play.js +++ b/web/play.js @@ -67,12 +67,33 @@ // anything moves on its own. var PREFS = 'rfog.prefs'; var PREF = { speed: 'normal', board: 'fibre', motion: 'on' }; + // The boards: light squares, dark squares, walls. The terminal client + // has the same list (render.Boards in Go; web/boards_test.go keeps the + // two equal), so a board looks the same wherever it is played. + var BOARDS = [ + ['fibre', '#52778a', '#3d5a6b', '#0d161c'], + ['graphite', '#6e6e6c', '#555553', '#1a1a19'], + ['daylight', '#9aa3ab', '#6c757d', '#22262a'], + ['abyss', '#5d6976', '#4a5561', '#0b0d10'], + ['nord', '#81a1c1', '#5e81ac', '#2e3440'], + ['gruvbox', '#a89984', '#7c6f64', '#282828'], + ['catppuccin', '#9399b2', '#6c7086', '#11111b'], + ['tokyo-night', '#737aa2', '#565f89', '#16161e'], + ['amber', '#b08a3a', '#7d5f22', '#1f1606'], + ['green', '#3f8a46', '#2f6b35', '#0a1f0c'] + ]; + function board(name) { return BOARDS.filter(function (b) { return b[0] === name; })[0] || BOARDS[0]; } var SPEEDS = { fast: 0.55, normal: 1, slow: 1.6, instant: 0 }; try { var pp = JSON.parse(stored(PREFS) || '{}'); for (var k in PREF) { if (pp[k]) { PREF[k] = pp[k]; } } } catch (e) { /* defaults */ } function K() { return SPEEDS[PREF.speed] === undefined ? 1 : SPEEDS[PREF.speed]; } function applyPrefs() { var root = document.documentElement; - root.dataset.board = PREF.board; + var b = board(PREF.board); + PREF.board = b[0]; + root.dataset.board = b[0]; + root.style.setProperty('--light', b[1]); + root.style.setProperty('--dark', b[2]); + root.style.setProperty('--wall', b[3]); root.dataset.motion = PREF.motion; root.style.setProperty('--step', (0.14 * K()) + 's'); keep(PREFS, JSON.stringify(PREF)); @@ -848,7 +869,7 @@ break; case 'display': h += '

Display

' + - row('Board', '
' + [['fibre', '#52778a', '#3d5a6b'], ['graphite', '#6e6e6c', '#555553'], ['daylight', '#9aa3ab', '#6c757d'], ['abyss', '#383d43', '#2a2e33']].map(function (b) { + row('Board', '
' + BOARDS.map(function (b) { return ''; }).join('') + '
') + row('Ambient motion', seg('motion', [['on', 'On'], ['off', 'Off']], PREF.motion), 'idle animations, signal pulses, ripples'); break; @@ -2464,7 +2485,15 @@ var seg = document.createElement('div'); seg.className = 'seg'; values.forEach(function (v) { var b = document.createElement('button'); - b.textContent = v; + if (key === 'board') { // a swatch per board: nine names do not fit a phone + var c = board(v); + b.title = v; + b.setAttribute('aria-label', v); + b.innerHTML = ''; + seg.classList.add('swatchseg'); + } else { + b.textContent = v; + } if (PREF[key] === v) { b.className = 'on'; } b.onclick = function () { PREF[key] = v; applyPrefs(); settings(); if (S) { render(); } }; seg.appendChild(b); @@ -2473,7 +2502,7 @@ sh.appendChild(r); }; row('Playback', 'speed', ['slow', 'normal', 'fast', 'instant']); - row('Board', 'board', ['fibre', 'graphite', 'daylight', 'abyss']); + row('Board', 'board', BOARDS.map(function (b) { return b[0]; })); row('Ambient motion', 'motion', ['on', 'off']); var close = document.createElement('button'); close.className = 'close'; diff --git a/web/web.go b/web/web.go index 14862c2..cf87161 100644 --- a/web/web.go +++ b/web/web.go @@ -9,7 +9,7 @@ import ( "io/fs" ) -//go:embed index.html play.js play.css sprites.png engine.wasm wasm_exec.js icon.svg favicon-32.png apple-touch-icon.png icon-192.png icon-512.png manifest.webmanifest +//go:embed index.html play.js play.css sprites.png engine.wasm wasm_exec.js icon.svg favicon-32.png apple-touch-icon.png icon-192.png icon-512.png og.png manifest.webmanifest var files embed.FS // Files is the static site served at /.