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.
-
-
-
+
+
+
## 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
-
+
-
-
+
+
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 += '