From cf8692d799b76dd2cc0209893d1305f1e08d5a28 Mon Sep 17 00:00:00 2001
From: zblauser <54143933+zblauser@users.noreply.github.com>
Date: Thu, 8 Oct 2026 12:19:58 -0400
Subject: [PATCH] v0.0.3: shared board themes, stats, home top bar, 1v1 docs
---
CHANGELOG.md | 30 ++++--
README.md | 48 +++++-----
client/challenge.go | 1 +
client/config.go | 29 ++++--
client/draft.go | 4 +-
client/home.go | 88 +++++++++++++++---
client/home_test.go | 75 +++++++++++++++
client/layout_test.go | 5 +-
client/lists.go | 6 +-
client/match.go | 4 +-
client/online.go | 29 +++++-
client/spectate.go | 2 +-
cmd/rfog/main.go | 3 +
cmd/rfog/serve.go | 32 +++++++
cmd/rfog/stubs.go | 2 +-
docs/CONTRIBUTING.md | 9 ++
docs/MANUAL.md | 20 ++--
docs/screenshots/terminal-home.png | Bin 144838 -> 0 bytes
docs/screenshots/terminal-match.png | Bin 97594 -> 0 bytes
docs/screenshots/terminal-roster.png | Bin 113642 -> 0 bytes
docs/screenshots/web-phone-home.jpg | Bin 65408 -> 0 bytes
docs/screenshots/web-phone-match.jpg | Bin 84859 -> 0 bytes
docs/screenshots/web-phone-replay.jpg | Bin 86194 -> 0 bytes
render/boards.go | 127 ++++++++++++++++++++++++++
render/boards_test.go | 50 ++++++++++
render/color256.go | 13 ++-
render/palette/palette.go | 27 ++++++
render/theme.go | 84 ++++++-----------
server/server.go | 27 +++++-
server/stats.go | 78 ++++++++++++++++
server/stats_test.go | 57 ++++++++++++
server/store/sql.go | 120 ++++++++++++++++++++++++
server/store/store.go | 23 +++++
server/store/store_test.go | 44 +++++++++
web/boards_test.go | 45 +++++++++
web/index.html | 12 ++-
web/og.png | Bin 0 -> 234231 bytes
web/play.css | 12 ++-
web/play.js | 37 +++++++-
web/web.go | 2 +-
40 files changed, 996 insertions(+), 149 deletions(-)
create mode 100644 client/home_test.go
delete mode 100644 docs/screenshots/terminal-home.png
delete mode 100644 docs/screenshots/terminal-match.png
delete mode 100644 docs/screenshots/terminal-roster.png
delete mode 100644 docs/screenshots/web-phone-home.jpg
delete mode 100644 docs/screenshots/web-phone-match.jpg
delete mode 100644 docs/screenshots/web-phone-replay.jpg
create mode 100644 render/boards.go
create mode 100644 render/boards_test.go
create mode 100644 server/stats.go
create mode 100644 server/stats_test.go
create mode 100644 web/boards_test.go
create mode 100644 web/og.png
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 64b0c0421b2685506c69c57ad6577c58fc49866c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 144838
zcmeFZWmH?;+wR*wZJ{j%ikG5Ai&NZbX>faRcPs7$rv?Ofw-zn#E@`270>y#^NU@;7
zUBX#?-uF3UkFoz_kG(&fG4_X(kq-f~Le`vX-t)Sz`**L1zt!aNA3uM5=gu8`MFo)N
zojW+M?%cUg|L`y1H!ov1neW`ab4L;MM%#C8XTdvAYx)XzpLMZNG1Gr*SI%gD!6fX-
z%=;Kh{`7WN=p&RfEYz77=U!NkTgmGL%`c0ZV0A(4)rt)HoMzcquDr9T>8NA$Dtfhb
zeR2M3HQUp`G9nuIKmYYmfy6xh_x1aT7pxEd{h>H4oAlq;>CZiY@AzL2bB6&%;M@K4
zcwhQI9?&QIaF?zp?x)*lnH6R`+nBP^QCVyM_jljQeLkc4Thq9~Z-EDbbF6I{VSR8|
z67lY;_~fE>mL&l7p-1jyYoVG~dM^(eEH43kx(fPdYZR#Aav~1wa)mqgZs&WgmTj{c
z3ZC<-kzSfGVKb992rWw+XV+nUGm6q>V>dc9q`Uq8Dyew;-e9e%o!D*|
z%Ky04u-g4LKzTH0vUJMCXV{P1XsOnz7xO}=l!K#8i}-QeF3${9xq8a7*)_sOU6O~F
zq|Y=`%JbTdzn3<6*&t!y`Z7hCHIDlAFR|?Utp3T_qlgVuj?six64BG2bbkiVpEqK%
z8$N-YT9}ZrWh0JiH8wR-RSh;(Gu~P2$gb#HdtfUgOvuBkCoJCjX|g-9$98&ZiX6Sp
zY#aygNQm_HF*~csBhBh3(MLYUnK2}Mw#8?+l(J5zlaiL%Lcl46JiD0AvE!yE--beb
zeX|05k#V`tZkNh00-C`E&V%TK2SPr7QHgKg?J>6HiZ-}_wB^sYd_T}wg54_(am#Bo
z8oQ((d?qa=K+KP5&Rvc#9a=)FpXHvBEv-%PlL3yjU2+o#>f<(sB49pz|csmeB!N~)zMAdRu
zc!LeWv!7Y}PN%dvAP|m($j)mCIs*&JG4*9S((94+SEF2SNuV4otjwuOS!|4{^MOtI
z6{JM>{+#5UcdR*TX^GF$5})d6r&muoI6Gl3@o9HAcAn33)D^c~j$iQig{GXGutm6M
z2#+f%M>>?1H$1O|4VCHbVWqGi7K1+sPU+ai(rx~zRZ@&rSB&0Epu^JK$t)||
zZR8TviHzi+u*@|uq@+*==?39ONCvqTwwFiN@i8PSWWp5}wq&QU!T-H@&suk2$1%_w
z-&NFt0j2QP_SPi3PO!_!ZY9;QvNGglKI#s^SrdVPJmHRL1K7@^`(%7M|FTW2(NM=&
z-|gLAEbLf-;ql`rCte*AFN(9^*rRz2RpsRqCWfeA4_PuSpXVy;J=F*bcG)5vJqwb;v3!I5G!?VTMxxf$pOr=k$WU9&Su^2BtK^he`
z`O+GdxxEd1?Vzljl+u1MZ6vR$!Mlz
z;OaX)FqogeKEJ!0zeEjt(eIi53kYC*>ZmB?pygo-#{aAzc;2M
zCeul&*}u_Cy=xvex`^J;2Q!rI3l~S7ql!}tnT6SQ7PHvKh|WNhb*|dzjp63nsZwok
zQG;GqLc5!r@TR7|mh{%}=AS(Aki%2wY19k+u^>ux(7KQ~EruJ#;nXDX66Q0YU{Eb(
z>*JAI)Y#Ni%*|TAx-+=LwJoUgc%^$cDm6f|R#QtW$bZP(IVh+{jwO{~)-w`L!4z_#
zaf;>^9%(&9fNU)z8tOWtA#&>3TU1X~K;q5UDOo9RK(K+Xp&^e>(-3|s&eYCNN#SX6
zpX$r?8OS0)>?vh+`4aq+Cx$TDHOw3H-9+<_p9oXzYT0yjTE&&Us<)QO(S6(QcvGS(
zT2nq{N2$E(ZxsrV_U8d?hTwJ4ozB4Pw~Uwi$hq3toy6sQ<@M3y0sC7{>(nGWczwLV
zbDl?0JL@ZS9iB&dYt3~gRrQF)tiUQGIofMTdU|ccfz&<<-gi|XX`Es4IuzsYPOP(`?
z_0FBwcm`)wI}!n3ZLC9LGboAJlp`Z!cSq;Wje;zKSvw0l=a)iIf$OU~iBIXy#y!JPuHf^M!5TwY140iZ`7{j1s&E_z@qyckB$#>`CZ>sLmpBYiC5}sY3utk
za-HmdxLMC1c^6$pA|FF}g}9=lg#H?pe&5RV<;PAbNmSJHTr!m`QO7mqYJ|3)I<=(n
z?r)KhrTnfN+_{-K;A~AgJ=WqHrPtTcO(j@K^^-~tRjE8w;$aw`KxOg0-GiyaBjJhc
zTj|8^$m;)n+e&)2|5+drmqTGeMmEGQnsKg$H*_Q#thv^TqOvT-|vEEq3H_N!!a
z^YUL+ZyFj9KI7}|=W!ctf;u{uQ9%nw-@gdLA-LciPP6
z2YZF*gjAAJFFBW?J?%+RJx|i2*44lYL32$(2AqhWl!Ps90em5mAoODJ-CU{QEAUNH
z0-{E~EW~XCQk(Q-{V6p;Kf=_-k>`w%V4>67Y7Ha&h4;;slg=M~V(7)pmVnc7`C(zd
zw%KHUcBWOLX2yQ*9-E^SeD+i?99kMxzP%-gLe3)1S_3zyC~~VSZ>ojETlG8q;msbn
z{aa;ad(ex^)q{c)Z|^rRzN$+@hlY{s1*5t=MgnG~+Rv+<2lk2fg3lj?_q@v1YH1As
zp3%<)(LLZrv6c`sT*N#N{r(Zpv;vol+N0%-)t!x17{xv+Xr)nT8Y-W-xK*Y(yD|NP
z<>DNdNW;WgRaQ2@&cJ@^!YEDh*3}|t&JR0Z;Gucwu9bmnGiH~``MYUfqd7EA$TavQ
zg$#&tRM_P62RI#vQc>_yHB}|-Vb4%YIrZ0H!K=P|WIT8@Id7%T54-C`{TJYqr8C2E
zet&1BEcTpQkY}y%1uVfAM0S<3rVFMWI4pU{^29XVMcaf}m5q#g$fCGpx`Xn9RDqrI
za!}ZAJ$3@MRaU^x!zJbZEBKhHpRJy1y^tE%Bl5c$Z-q8YrexnuZf%vZ*MhpA{8~AB
zz{la(XxO`QfLV=g9@o)q3*uwzR7=at%E`^lPRr3T(5TX6yZDPASH9#8W1s#2XTN&Z
z&Ga1IljdOF+=QyNvxWL3akX@*@SY`Y4$?6V)_Ojd$?55gtf7sh1GK3mo8oTYKn`E0x6fRVP8$~k%!4BTeQf?}xXtJk>inJ?mSwlitdr>^R
z>55+pnfheqbILTe4S{9xT!_66^@X5R9obY}*_qvI3H&J<>MvVbktQyJPsRVn)?Uzm
zc@o&g^|ef#jXloOBRI*+8(KDwYu|=mv>MZ*kkcW*Ycw?RW@iTZn_SM}`&qe<=Ik6-
z9^JX)rWcr@vuB1S7i$x^;50HvpLH8q(%t^N4Jf~dngp)DC_$8=p`mP!)XAwMPr1up
zTix+k|0+SOSkCTR%BU&SC&l2I=jQSdnYznN9T45UFDpbtZco-j^tz3AqpCYbmfSRx
zlAuOMHXfGwN<|Iz&1c7lwEV&OhLu957YT21
z8CP%P>P4u%ZF<-2Xt|-(m0E~$jv>z`XY1Hc#L(BsuZ*|icNQ}13>WHF9h<+WDA;sf
zeh%r>eNHL|vZ2VIBN;y&UgwcKFh`g?xf+aGpoDv7ReEmM%W%
zQ`PNiV+k`rUTPz@PyB6yO!{Ib@3l7*B}~fL?rltt7~<0iruHmNYU?>K>>B}kW-img
zR;HYTV$}8KsF?-#^5Fb>cuYf&JGZ*LQlm7P(w=QyjfRmSqbS8a54IcIvvmLSXCjU6
zlNp>(u*C%sx&nxA$Om&Gv1j8b@mQj=kb{@_kv7%VL=$G%veuG2yWRKH%UQKiw_p%HGJiD60)*>z
zJei)WXKB$J!trq1>FFuV{GpzUPqADIp+Cm@kADC1ySnRkY&lI9
zoh9i)LrT)Du@^@PAFwgEl;A{5cM+j`VZ<8PF3c8Ey$+8MqmfEVlmL&{xOKkwH6C*O
zJm%{*jns%->f&j_%ryV&&-F7fjxwg+%aQ*S@P!=u<1^8bGeeDImy~cDP&gIvA^wR$!QC0t7~He+mVxnY^6kKlK?J||1sv2qtVtkwGF2u{p5
zqWO{@tf|8Cjh;u|(qd@`ZNUoW9Dpx*w1Tw#u9_dl_2WIk2cI3*HZ;8=Nw0MsHTOV*
zc{Fk5?a*&*4LOSY%PTAGd1>k_M%ZZ0epz)@6*k9~kd%zt7pH?5G!WC?1E&s4X$;G#
z7YEH$$l(
zx-Nr>RbL<0v@B>^$noqJx{mhLqbGY~HY1~mnHeD@RNZ;ASd72TEhlehXCp+LJ*APa
zmgj_?6`F*GCn#{SsAR#>Zjp}<)oB+|DvGuuSs8!7tZyKQ+F4nn`u%&y1A80+Bry#a
z3rHU<;=$;xk$&E3o-}wRTf3Xp6^)sb0R<{Mwi8)(=J^laEcMIonogSrv`Ln)Eo6$k
zotpfjWn=${=%A3VhZtTNknF*%*|XmXEIh`0HxUCiyKlQjyXH(z7fGzH9LmJ8-y7Gzgc0Wyall;rOV%oxJ3C66D-33=fUG6WU!vyZjP&%}%=PqQEo2SYh+Z8j
z&JNNfpPa1O6u}M#Om_$7FW(etK0gD+8yKqQbiNK;H)9vUY+JMPQII6)7T8;y;met*
z_H>8sISFM=S#{N(bBT9(wsa88nu=964Q{Ry{aJRim5Mi1!dMnz5D=;RFh)j5n9PR^
z17}ry*}qKlIy-1#E#osqtjpl!93Xxlc#A&p6YadBIHM70*!%Jo
z*tEcHR33QjzqXE=tNgI2?#qUw>sG6U`e4nn!|`lqs;S7K-)Fi=d^#&>Tg|1fofv&o
z<+)JGj8InqL&0SqJR%h|;*uiyPN|fD^m^BUKLFR+86MM!O3jus%*!j$LQ&UCTAPuY
z*-o7^XFAA^B?J*9*@~%Uz*h^YGiKq0m@zL5M_y#sO(7=;jkb&s;FpYXIiaudbmL@)
zl^|I~Sn~m+F{((jQOSF0r!=}DnO(N%6jA7}oEsRs>F4Nwc~#(ErY-C$a@ZedMy1ZV
z5Pj-Yor-e(>tHYbT$rS<5BI2Psw4*4d_kvn64Mo%dHIqvsrlbI$RJYQPNg8%M>ZW{
zyu=nt=z{(>(>ogprz514vP9fF!p?S-Y9M~wvRfclA2Bocav2{L;f)>Hs?qSf$bj~w
zuy^kYHTeKJ1@Nq=O+Q=qRA*&o@tWD3SlCUQZn`{sA|VwrRN=?RvAxCKTUAL#^(3dg
zJRQ%D@si;8p)cT76_p_Uw~r8cP;dCrEjgkjXML0=D##VpwZGq07iAYQa227m;}h+z
z$-(YIOIMjGBE^R{)qE+JF!oe>7DFE4Z9m<5K>tQAL3;yoROea
zw{$O|KszB7KtlJzkGLxR_>{BWFhX8}`%xwm^pQi4bJY5m+~y@ZBfjzSghSF7HGNJA
zh;2W57JLZMMKm>?dN7DJxUQ!}t#b?wKcR?sB9Q+@#5kUT#iQodvv!F+%I!mLquFA=
z$TDWD)^t9-C5R3QT7c7b$9TSyN$|Vcp0HKjjh0D(K#u)`I_tXL+{os$3}=xfxbtj`
zxa3vF03O2k1)g`qMq}X$26Kx~hFzs^&-Zdo`eUi*>K$>3BoB`3gRytLBtLOZd5#wX@^DO+nf@sn+`XIhooynIO>N!Ndmk>Z?AOLwz(!D93~^3Dtoz
zmWB1=s1J5u3Gkn^Gb|bESW-s~r>Lj1(20lkuIV!Oau8R858iK__@l8}rd@YI$svxU
z?r%&9%;?>Gj=mtT6e+`IR@beC^;44SIkFb!U3y_=edu|c>0zk%)jcqe*+(Q9*(yJH
z-P_H>Me*X)iHA4nS~&_Y12>Gy?D>qiNF!9r*_JyM!kj3l