Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9d352555f | ||
|
|
19d4d4577d | ||
|
|
8cbc641d20 | ||
|
|
ec15c2f52a | ||
|
|
68d50b320f | ||
|
|
5c660d866d | ||
|
|
8aa2848c5f | ||
|
|
472f13260f | ||
|
|
468dc0dc0d | ||
|
|
7e14e270df | ||
|
|
c3fd9909a1 | ||
|
|
8ba0102910 | ||
|
|
17651cb911 | ||
|
|
5af695f43a |
@@ -186,6 +186,10 @@ Installing a new firmware image without a USB cable: pushed over Wi-Fi from the
|
||||
A tag `v*` on the project's Gitea with a signed **Update File**, a factory image for USB, the ELF to decode crashes and checksums, built and published by CI. The device reads them to look for updates.
|
||||
_Avoid_: flash, upgrade (alone)
|
||||
|
||||
**Game**:
|
||||
Something played inside the Games App: Pong, Snake or Tetris. A Game is not an App: it has no tile in the Launcher, and is found again paused when the Games App is left in the middle of one.
|
||||
_Avoid_: mini-app, toy
|
||||
|
||||
**Update File**:
|
||||
One signed file (`.ota`) that carries a firmware version, its image and a signature. The same file works over Wi-Fi and from the SD card. Anything not signed with the project's key is refused.
|
||||
_Avoid_: binary, bin
|
||||
|
||||
@@ -16,6 +16,7 @@ What it does today:
|
||||
- **Shell:** the firmware's commands on the device itself, with completion, including `ping`, `nslookup`, `port`, `traceroute`, `tls` and `ifconfig`.
|
||||
- **System:** load, tasks, memory, network, battery, live.
|
||||
- **SSH:** a terminal on another machine, with a password or the device's own key.
|
||||
- **Games:** Pong, Snake and Tetris.
|
||||
- **VPN:** a WireGuard tunnel.
|
||||
- **Everywhere:** Fn+h lists the keys of the screen you are on; Fn+p takes a screenshot.
|
||||
- **Updates:** signed, from the project's server, the card or a PC, with a rollback if the new firmware fails.
|
||||
@@ -104,9 +105,10 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
|
||||
|
||||
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
|
||||
|
||||
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update.
|
||||
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
|
||||
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
|
||||
- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
|
||||
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
|
||||
|
||||
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
|
||||
|
||||
@@ -186,6 +188,12 @@ The SSH App (docs/milestones/N1.md, issue #2) is a terminal on another machine:
|
||||
|
||||
The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`, `nano` and `vim` send: the cursor, erasing, sixteen colours, scroll regions, the alternate screen; no mouse. Five text sizes with Ctrl and + or -, from 60 x 20 to 26 x 8, told to the far end; 100 lines of scrollback with Alt and up or down. The backtick key sends Esc. **Leaving the App doesn't end the session:** `SSH` shows in the Status Bar and the App finds it again. It costs 292 KB of flash (109 KB of it one table, for signing with the device's key) and about 50 KB of memory while a session is open, so it isn't started under 75 KB free and IRC doesn't connect while one is open.
|
||||
|
||||
## Games
|
||||
|
||||
The Games App (issue #101) holds three Games, shown as a row of icons like Apps in the Launcher: Pong against the device (first to 7; the device's paddle has a top speed, so it can be beaten), Snake (38 x 19, the walls kill) and Tetris (10 x 20, the 7-bag, the next piece shown, a turn that shifts away from a wall, soft and hard drop). Each is a state machine in `lib/games`, host-tested: time and keys go in, the new state and what happened (a bounce, a line) come out, and a seed decides the dice, so a rule can be tested without playing.
|
||||
|
||||
Apps used to hear only key presses. `KeyMapper::held()` says which keys are down, for a paddle that moves while a key is held and for Tetris's repeat. An App that is in use with no key pressed says so (`App::keepsScreenOn`): the screen stays lit, and an update waiting for a quiet moment keeps waiting. A Game left in the middle is found again paused. The best of each Game (for Pong, the longest rally) is kept in internal flash. Tetris plays Korobeiniki, the folk tune it has always had, a note at a time on a speaker channel of its own (`lib/games/src/tune.h`, host-tested timing); `0` switches it, `8` and `9` set its loudness, both kept; it hurries with each level. The beeps and the tune obey Settings > This device > Sound & LED.
|
||||
|
||||
## VPN
|
||||
|
||||
A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > Network > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered.
|
||||
@@ -233,7 +241,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
|
||||
|
After Width: | Height: | Size: 124 B |
|
After Width: | Height: | Size: 112 B |
|
After Width: | Height: | Size: 115 B |
|
After Width: | Height: | Size: 99 B |
@@ -1,6 +1,6 @@
|
||||
# R1 — Releases
|
||||
|
||||
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54).
|
||||
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
|
||||
|
||||
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
|
||||
|
||||
@@ -124,6 +124,69 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
|
||||
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
|
||||
|
||||
## Installing a release by itself (issue #52)
|
||||
|
||||
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
|
||||
|
||||
### Decisions (2026-10-11)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
|
||||
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
|
||||
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
|
||||
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
|
||||
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
|
||||
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
|
||||
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
|
||||
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
|
||||
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
|
||||
- `update status` says whether a release is waiting for its quiet moment.
|
||||
|
||||
### Checked on the device (2026-10-11)
|
||||
|
||||
With the setting on, `update pretend v0.22.0` and `update daily`:
|
||||
|
||||
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
|
||||
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
|
||||
- `/system/updates.log` got its line at each confirmed update.
|
||||
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
|
||||
|
||||
## Carrying on a download that broke (issue #54)
|
||||
|
||||
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
|
||||
|
||||
### What the server gives (checked 2026-10-11)
|
||||
|
||||
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
|
||||
|
||||
### Decisions (2026-10-11)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
|
||||
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
|
||||
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
|
||||
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
|
||||
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
|
||||
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
|
||||
- `update status` says how often the last download was carried on.
|
||||
|
||||
### Checked on the device (2026-10-11)
|
||||
|
||||
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
|
||||
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
|
||||
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
|
||||
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
|
||||
|
||||
## One firmware: the Debug Console in every build (issue #68)
|
||||
|
||||
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
|
||||
|
||||
@@ -130,7 +130,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s
|
||||
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
|
||||
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
|
||||
| Network | Wi-Fi, VPN |
|
||||
| System | Check for updates, Firmware, Debug Console, About |
|
||||
| System | Check for updates, Install updates, Firmware, Debug Console, About |
|
||||
|
||||
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
|
||||
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
|
||||
|
||||
@@ -48,6 +48,7 @@ const Member kRows[] = {
|
||||
{3, {Row::Wifi, Kind::Page, "Wi-Fi"}},
|
||||
{3, {Row::Vpn, Kind::Page, "VPN"}},
|
||||
{4, {Row::CheckUpdates, Kind::Toggle, "Check for updates"}},
|
||||
{4, {Row::InstallUpdates, Kind::Toggle, "Install updates"}},
|
||||
{4, {Row::Firmware, Kind::Page, "Firmware"}},
|
||||
{4, {Row::DebugConsole, Kind::Page, "Debug Console"}},
|
||||
{4, {Row::About, Kind::Page, "About"}},
|
||||
@@ -152,6 +153,7 @@ std::string SettingsMenu::value(int i) const {
|
||||
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
|
||||
case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off";
|
||||
case Row::CheckUpdates: return settings_.getBool(Setting::CheckUpdates) ? "Daily" : "Off";
|
||||
case Row::InstallUpdates: return settings_.getBool(Setting::InstallUpdates) ? "By itself" : "When asked";
|
||||
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
|
||||
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
|
||||
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
|
||||
@@ -210,6 +212,7 @@ void SettingsMenu::toggle(int i) {
|
||||
if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled));
|
||||
if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora));
|
||||
if (row(i) == Row::CheckUpdates) settings_.setBool(Setting::CheckUpdates, !settings_.getBool(Setting::CheckUpdates));
|
||||
if (row(i) == Row::InstallUpdates) settings_.setBool(Setting::InstallUpdates, !settings_.getBool(Setting::InstallUpdates));
|
||||
if (row(i) == Row::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms));
|
||||
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace roro {
|
||||
// Rendering and navigation live in the App. Every index is into what is listed now.
|
||||
class SettingsMenu {
|
||||
public:
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About,
|
||||
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, InstallUpdates, Firmware, Vpn, DebugConsole, About,
|
||||
GroupDevice, GroupDisplay, GroupPosition, GroupNetwork, GroupSystem };
|
||||
enum class Kind { Text, Choice, Toggle, Slider, Page, Group };
|
||||
|
||||
|
||||
@@ -50,6 +50,10 @@ class App {
|
||||
virtual bool retainsContent() const { return false; }
|
||||
virtual void contentLost() {}
|
||||
|
||||
// True while the App is in use with no key being pressed (a Game being played, issue #101):
|
||||
// it counts as activity, so the screen neither dims nor goes off.
|
||||
virtual bool keepsScreenOn() const { return false; }
|
||||
|
||||
// True while something goes on in the App that is worth a look, whether it is in front or
|
||||
// not (unread messages, a recording running): the Launcher marks its tile (issue #9).
|
||||
virtual bool badge() const { return false; }
|
||||
|
||||
@@ -329,6 +329,42 @@ inline constexpr KeyHelp kVpn[] = {
|
||||
{"; .", "up, down"},
|
||||
};
|
||||
|
||||
// games: Games, the three games
|
||||
inline constexpr KeyHelp kGames[] = {
|
||||
{"Enter", "this game"},
|
||||
{", /", "the game before, the next"},
|
||||
};
|
||||
|
||||
// games-pong: Games, Pong
|
||||
inline constexpr KeyHelp kGamesPong[] = {
|
||||
{"; .", "the paddle, while held"},
|
||||
{"Enter", "start, or start again"},
|
||||
{"p", "pause; any key carries on"},
|
||||
{"`", "the other games; this one waits"},
|
||||
};
|
||||
|
||||
// games-snake: Games, Snake
|
||||
inline constexpr KeyHelp kGamesSnake[] = {
|
||||
{"; . , /", "turn"},
|
||||
{"Enter", "start, or start again"},
|
||||
{"p", "pause; any key carries on"},
|
||||
{"`", "the other games; this one waits"},
|
||||
};
|
||||
|
||||
// games-tetris: Games, Tetris
|
||||
inline constexpr KeyHelp kGamesTetris[] = {
|
||||
{", /", "left, right; held, it repeats"},
|
||||
{";", "turn"},
|
||||
{"z", "turn the other way"},
|
||||
{".", "fall faster, while held"},
|
||||
{"1", "drop"},
|
||||
{"0", "the music, on or off"},
|
||||
{"8 9", "the music, quieter, louder"},
|
||||
{"Enter", "start, or start again"},
|
||||
{"p", "pause; any key carries on"},
|
||||
{"`", "the other games; this one waits"},
|
||||
};
|
||||
|
||||
// ssh: SSH, the hosts
|
||||
inline constexpr KeyHelp kSsh[] = {
|
||||
{"Enter", "connect, open"},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
// The Games' own dice (issue #101): the same seed gives the same game, which is what lets their
|
||||
// rules be tested. xorshift32; never 0.
|
||||
class Rng {
|
||||
public:
|
||||
explicit Rng(uint32_t seed) : s_(seed ? seed : 0x9E3779B9u) {}
|
||||
uint32_t next() {
|
||||
s_ ^= s_ << 13;
|
||||
s_ ^= s_ >> 17;
|
||||
s_ ^= s_ << 5;
|
||||
return s_;
|
||||
}
|
||||
int below(int n) { return n <= 1 ? 0 : static_cast<int>(next() % static_cast<uint32_t>(n)); }
|
||||
float unit() { return (next() >> 8) / 16777216.0f; } // 0 up to 1
|
||||
|
||||
private:
|
||||
uint32_t s_;
|
||||
};
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "pong.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
namespace {
|
||||
constexpr float kTick = 0.004f; // the ball moves a pixel at most between two looks
|
||||
constexpr float kSteepest = 0.87f; // 50 degrees, in radians: off the very end of a paddle
|
||||
float clampPaddle(float y) { return std::clamp(y, Pong::kPaddleH / 2, Pong::kHeight - Pong::kPaddleH / 2); }
|
||||
} // namespace
|
||||
|
||||
void Pong::start() {
|
||||
playerScore_ = deviceScore_ = rally_ = longest_ = 0;
|
||||
player_ = device_ = kHeight / 2;
|
||||
serve(rng_.below(2) == 0);
|
||||
}
|
||||
|
||||
void Pong::serve(bool toPlayer) {
|
||||
x_ = kWidth / 2;
|
||||
y_ = kHeight / 2;
|
||||
speed_ = kServeSpeed;
|
||||
float angle = (rng_.unit() - 0.5f) * 1.0f; // within 29 degrees of level
|
||||
vx_ = std::cos(angle) * speed_ * (toPlayer ? -1 : 1);
|
||||
vy_ = std::sin(angle) * speed_;
|
||||
rally_ = 0;
|
||||
wait_ = kServePause;
|
||||
phase_ = Phase::Serve;
|
||||
}
|
||||
|
||||
bool Pong::meets(float paddleY) const { return std::fabs(y_ - paddleY) <= kPaddleH / 2 + kBall / 2; }
|
||||
|
||||
// Where on the paddle it hit decides the angle it leaves at, and each return is a little faster.
|
||||
void Pong::bounceOff(float paddleY, bool toRight) {
|
||||
float where = std::clamp((y_ - paddleY) / (kPaddleH / 2), -1.0f, 1.0f);
|
||||
speed_ = std::min(kMaxSpeed, speed_ * kFaster);
|
||||
vx_ = std::cos(where * kSteepest) * speed_ * (toRight ? 1 : -1);
|
||||
vy_ = std::sin(where * kSteepest) * speed_;
|
||||
}
|
||||
|
||||
uint8_t Pong::step(float seconds, int move) {
|
||||
uint8_t events = 0;
|
||||
seconds = std::min(seconds, 0.25f); // a held-up loop is not a quarter of the field
|
||||
while (seconds > 0 && phase_ != Phase::Over) {
|
||||
float dt = std::min(seconds, kTick);
|
||||
seconds -= dt;
|
||||
events |= tick(dt, move);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
uint8_t Pong::tick(float dt, int move) {
|
||||
player_ = clampPaddle(player_ + (move < 0 ? -1 : move > 0 ? 1 : 0) * kPlayerSpeed * dt);
|
||||
// The device watches the ball once it comes its way, and waits in the middle otherwise.
|
||||
float target = vx_ > 0 && phase_ == Phase::Play ? y_ : kHeight / 2;
|
||||
float reach = kDeviceSpeed * dt;
|
||||
device_ = clampPaddle(device_ + std::clamp(target - device_, -reach, reach));
|
||||
|
||||
if (phase_ == Phase::Serve) {
|
||||
wait_ -= dt;
|
||||
if (wait_ <= 0) phase_ = Phase::Play;
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint8_t events = 0;
|
||||
float before = x_;
|
||||
x_ += vx_ * dt;
|
||||
y_ += vy_ * dt;
|
||||
if (y_ < kBall / 2) {
|
||||
y_ = kBall - y_;
|
||||
vy_ = -vy_;
|
||||
events |= Wall;
|
||||
} else if (y_ > kHeight - kBall / 2) {
|
||||
y_ = 2 * (kHeight - kBall / 2) - y_;
|
||||
vy_ = -vy_;
|
||||
events |= Wall;
|
||||
}
|
||||
|
||||
const float playerFace = kMargin + kPaddleW + kBall / 2, deviceFace = kWidth - kMargin - kPaddleW - kBall / 2;
|
||||
if (vx_ < 0 && before >= playerFace && x_ < playerFace && meets(player_)) {
|
||||
x_ = playerFace;
|
||||
bounceOff(player_, true);
|
||||
rally_++;
|
||||
longest_ = std::max(longest_, rally_);
|
||||
events |= Hit;
|
||||
} else if (vx_ > 0 && before <= deviceFace && x_ > deviceFace && meets(device_)) {
|
||||
x_ = deviceFace;
|
||||
bounceOff(device_, false);
|
||||
events |= Hit;
|
||||
} else if (x_ < 0 || x_ > kWidth) {
|
||||
bool playerScored = x_ > kWidth;
|
||||
(playerScored ? playerScore_ : deviceScore_)++;
|
||||
events |= Point;
|
||||
if (playerScore_ >= kWinScore || deviceScore_ >= kWinScore) {
|
||||
phase_ = Phase::Over;
|
||||
events |= Over;
|
||||
} else serve(playerScored); // to whoever lost the point
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "game_rng.h"
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
// Pong against the device (issue #101): the player's paddle on the left, the device's on the
|
||||
// right, first to 7. The ball goes faster with each return, and the device's paddle only moves so
|
||||
// fast: a steep, fast ball gets past it. Lengths are pixels of the 240 x 122 field, times seconds.
|
||||
class Pong {
|
||||
public:
|
||||
static constexpr float kWidth = 240, kHeight = 122;
|
||||
static constexpr float kPaddleH = 24, kPaddleW = 3, kMargin = 6, kBall = 4;
|
||||
static constexpr float kPlayerSpeed = 150, kDeviceSpeed = 88;
|
||||
static constexpr float kServeSpeed = 110, kMaxSpeed = 270, kFaster = 1.06f;
|
||||
static constexpr float kServePause = 0.8f;
|
||||
static constexpr int kWinScore = 7;
|
||||
|
||||
enum Event : uint8_t { Wall = 1, Hit = 2, Point = 4, Over = 8 };
|
||||
enum class Phase : uint8_t { Serve, Play, Over };
|
||||
|
||||
explicit Pong(uint32_t seed) : rng_(seed) { start(); }
|
||||
void start();
|
||||
// `seconds` later, with the player's paddle asked up (-1), down (1) or left alone (0).
|
||||
// What happened, as Events.
|
||||
uint8_t step(float seconds, int move);
|
||||
|
||||
Phase phase() const { return phase_; }
|
||||
float ballX() const { return x_; }
|
||||
float ballY() const { return y_; }
|
||||
float speed() const { return speed_; }
|
||||
float playerY() const { return player_; } // the paddle's middle
|
||||
float deviceY() const { return device_; }
|
||||
int playerScore() const { return playerScore_; }
|
||||
int deviceScore() const { return deviceScore_; }
|
||||
bool playerWon() const { return playerScore_ >= kWinScore; }
|
||||
int rally() const { return rally_; } // the player's returns since the serve
|
||||
int longestRally() const { return longest_; } // in this game
|
||||
|
||||
private:
|
||||
uint8_t tick(float dt, int move);
|
||||
void serve(bool toPlayer);
|
||||
bool meets(float paddleY) const;
|
||||
void bounceOff(float paddleY, bool toRight);
|
||||
|
||||
Rng rng_;
|
||||
Phase phase_ = Phase::Serve;
|
||||
float x_ = 0, y_ = 0, vx_ = 0, vy_ = 0, speed_ = 0;
|
||||
float player_ = 0, device_ = 0, wait_ = 0;
|
||||
int playerScore_ = 0, deviceScore_ = 0, rally_ = 0, longest_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -0,0 +1,92 @@
|
||||
#include "snake.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
namespace {
|
||||
bool opposite(Snake::Dir a, Snake::Dir b) { return (static_cast<int>(a) + 2) % 4 == static_cast<int>(b); }
|
||||
} // namespace
|
||||
|
||||
void Snake::start() {
|
||||
body_.clear();
|
||||
taken_.fill(false);
|
||||
for (int i = 0; i < 3; i++) { // three cells in the middle, heading right
|
||||
Cell c{static_cast<int8_t>(kCols / 2 - i), static_cast<int8_t>(kRows / 2)};
|
||||
body_.push_back(c);
|
||||
mark(c, true);
|
||||
}
|
||||
dir_ = Dir::Right;
|
||||
turnCount_ = 0;
|
||||
score_ = 0;
|
||||
sinceMs_ = 0;
|
||||
alive_ = true;
|
||||
placeFood();
|
||||
}
|
||||
|
||||
uint32_t Snake::intervalMs() const {
|
||||
uint32_t off = static_cast<uint32_t>(score_ / kFoodsAStep) * kFasterMs;
|
||||
return off >= kSlowestMs - kFastestMs ? kFastestMs : kSlowestMs - off;
|
||||
}
|
||||
|
||||
void Snake::turn(Dir dir) {
|
||||
Dir last = turnCount_ ? turns_[turnCount_ - 1] : dir_;
|
||||
if (dir == last || opposite(dir, last) || turnCount_ >= 2) return;
|
||||
turns_[turnCount_++] = dir;
|
||||
}
|
||||
|
||||
// On a free cell, each as likely as another.
|
||||
void Snake::placeFood() {
|
||||
int free = kCols * kRows - static_cast<int>(body_.size());
|
||||
if (free <= 0) return;
|
||||
int pick = rng_.below(free);
|
||||
for (int i = 0; i < kCols * kRows; i++) {
|
||||
if (taken_[static_cast<size_t>(i)]) continue;
|
||||
if (pick-- == 0) {
|
||||
food_ = {static_cast<int8_t>(i % kCols), static_cast<int8_t>(i / kCols)};
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t Snake::step(uint32_t ms) {
|
||||
uint8_t events = 0;
|
||||
if (!alive_) return 0;
|
||||
sinceMs_ += std::min<uint32_t>(ms, 500);
|
||||
while (alive_ && sinceMs_ >= intervalMs()) {
|
||||
sinceMs_ -= intervalMs();
|
||||
events |= move();
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
uint8_t Snake::move() {
|
||||
if (turnCount_) {
|
||||
dir_ = turns_[0];
|
||||
turns_[0] = turns_[1];
|
||||
turnCount_--;
|
||||
}
|
||||
static const int8_t dc[4] = {0, 1, 0, -1}, dr[4] = {-1, 0, 1, 0};
|
||||
Cell head{static_cast<int8_t>(body_.front().col + dc[static_cast<int>(dir_)]),
|
||||
static_cast<int8_t>(body_.front().row + dr[static_cast<int>(dir_)])};
|
||||
bool eats = head == food_;
|
||||
// The tail moves on as the head arrives: the head may go where the tail was. A snake that
|
||||
// dies stays as it was, whole.
|
||||
bool ontoTail = !eats && head == body_.back();
|
||||
if (head.col < 0 || head.col >= kCols || head.row < 0 || head.row >= kRows || (taken(head) && !ontoTail)) {
|
||||
alive_ = false;
|
||||
return Died;
|
||||
}
|
||||
if (!eats) {
|
||||
mark(body_.back(), false);
|
||||
body_.pop_back();
|
||||
}
|
||||
body_.push_front(head);
|
||||
mark(head, true);
|
||||
if (!eats) return Moved;
|
||||
score_++;
|
||||
placeFood();
|
||||
return Moved | Ate;
|
||||
}
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
|
||||
#include "game_rng.h"
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
// Snake (issue #101): a field of 38 x 19 cells, the walls and its own body kill it, and it goes
|
||||
// faster as it eats. Time comes in from outside, in milliseconds.
|
||||
class Snake {
|
||||
public:
|
||||
static constexpr int kCols = 38, kRows = 19;
|
||||
static constexpr uint32_t kSlowestMs = 150, kFastestMs = 60, kFasterMs = 5;
|
||||
static constexpr int kFoodsAStep = 3; // it gets faster every so many
|
||||
|
||||
enum class Dir : uint8_t { Up, Right, Down, Left };
|
||||
enum Event : uint8_t { Moved = 1, Ate = 2, Died = 4 };
|
||||
struct Cell {
|
||||
int8_t col, row;
|
||||
bool operator==(const Cell& o) const { return col == o.col && row == o.row; }
|
||||
};
|
||||
|
||||
explicit Snake(uint32_t seed) : rng_(seed) { start(); }
|
||||
void start();
|
||||
// A turn asked for; it is taken at the next move. Two are remembered, so a quick corner (up,
|
||||
// then left) works; turning back on itself is not a turn.
|
||||
void turn(Dir dir);
|
||||
uint8_t step(uint32_t ms);
|
||||
|
||||
bool alive() const { return alive_; }
|
||||
int score() const { return score_; } // foods eaten
|
||||
const std::deque<Cell>& body() const { return body_; } // the head first
|
||||
Cell food() const { return food_; }
|
||||
Dir heading() const { return dir_; }
|
||||
uint32_t intervalMs() const; // between two moves, now
|
||||
// For the tests: the food put where one wants it.
|
||||
void putFood(Cell at) { food_ = at; }
|
||||
|
||||
private:
|
||||
bool taken(Cell c) const { return taken_[static_cast<size_t>(c.row * kCols + c.col)]; }
|
||||
void mark(Cell c, bool on) { taken_[static_cast<size_t>(c.row * kCols + c.col)] = on; }
|
||||
void placeFood();
|
||||
uint8_t move();
|
||||
|
||||
Rng rng_;
|
||||
std::deque<Cell> body_;
|
||||
std::array<bool, kCols * kRows> taken_{};
|
||||
Cell food_{0, 0};
|
||||
Dir dir_ = Dir::Right;
|
||||
Dir turns_[2] = {Dir::Right, Dir::Right};
|
||||
int turnCount_ = 0, score_ = 0;
|
||||
uint32_t sinceMs_ = 0;
|
||||
bool alive_ = true;
|
||||
};
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -0,0 +1,202 @@
|
||||
#include "tetris.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
namespace {
|
||||
|
||||
// Each piece in its four turns, as the cells it fills in a 4 x 4 box: bit (row * 4 + col), the
|
||||
// top left first. They turn as in the Super Rotation System.
|
||||
constexpr uint16_t bits(const char* rows) {
|
||||
uint16_t v = 0;
|
||||
for (int i = 0; i < 16; i++)
|
||||
if (rows[i] == '#') v |= static_cast<uint16_t>(1u << i);
|
||||
return v;
|
||||
}
|
||||
constexpr uint16_t kShapes[7][4] = {
|
||||
{bits("....####........"), bits("..#...#...#...#."), bits("........####...."), bits(".#...#...#...#..")}, // I
|
||||
{bits(".##..##........."), bits(".##..##........."), bits(".##..##........."), bits(".##..##.........")}, // O
|
||||
{bits(".#..###........."), bits(".#...##..#......"), bits("....###..#......"), bits(".#..##...#......")}, // T
|
||||
{bits(".##.##.........."), bits(".#...##...#....."), bits(".....##.##......"), bits("#...##...#......")}, // S
|
||||
{bits("##...##........."), bits("..#..##..#......"), bits("....##...##....."), bits(".#..##..#.......")}, // Z
|
||||
{bits("#...###........."), bits(".##..#...#......"), bits("....###...#....."), bits(".#...#..##......")}, // J
|
||||
{bits("..#.###........."), bits(".#...#...##....."), bits("....###.#......."), bits("##...#...#......")}, // L
|
||||
};
|
||||
// A turn that does not fit where the piece is tries these shifts, in order: a step from a wall,
|
||||
// one up off the floor, two for the long piece.
|
||||
constexpr int8_t kKicks[6][2] = {{0, 0}, {-1, 0}, {1, 0}, {0, -1}, {-2, 0}, {2, 0}};
|
||||
constexpr int kLineScore[5] = {0, 100, 300, 500, 800};
|
||||
|
||||
} // namespace
|
||||
|
||||
std::array<Tetris::Cell, 4> Tetris::cells(const Piece& p) {
|
||||
std::array<Cell, 4> out{};
|
||||
if (p.kind < 1 || p.kind > 7) return out;
|
||||
uint16_t shape = kShapes[p.kind - 1][p.turn & 3];
|
||||
int n = 0;
|
||||
for (int i = 0; i < 16 && n < 4; i++)
|
||||
if (shape & (1u << i)) out[static_cast<size_t>(n++)] = {static_cast<int8_t>(p.col + i % 4), static_cast<int8_t>(p.row + i / 4)};
|
||||
return out;
|
||||
}
|
||||
|
||||
void Tetris::start() {
|
||||
well_.fill(0);
|
||||
score_ = lines_ = lastCleared_ = 0;
|
||||
fallSince_ = heldMs_ = 0;
|
||||
heldDir_ = 0;
|
||||
repeating_ = over_ = softSpent_ = false;
|
||||
bagLeft_ = 0;
|
||||
next_ = deal();
|
||||
spawn(deal());
|
||||
}
|
||||
|
||||
// The bag: the seven pieces in a shuffled order, then seven again.
|
||||
uint8_t Tetris::deal() {
|
||||
if (bagLeft_ == 0) {
|
||||
for (int i = 0; i < 7; i++) bag_[i] = static_cast<uint8_t>(i + 1);
|
||||
for (int i = 6; i > 0; i--) std::swap(bag_[i], bag_[rng_.below(i + 1)]);
|
||||
bagLeft_ = 7;
|
||||
}
|
||||
return bag_[--bagLeft_];
|
||||
}
|
||||
|
||||
uint32_t Tetris::fallMs() const {
|
||||
uint32_t ms = kSlowestMs;
|
||||
for (int i = 0; i < level() && ms > kFastestMs; i++) ms = ms * 85 / 100;
|
||||
return std::max(ms, kFastestMs);
|
||||
}
|
||||
|
||||
// Inside the walls and above the floor, on no cell already filled. Above the well is free.
|
||||
bool Tetris::fits(const Piece& p) const {
|
||||
for (const Cell& c : cells(p)) {
|
||||
if (c.col < 0 || c.col >= kCols || c.row >= kRows) return false;
|
||||
if (c.row >= 0 && cell(c.col, c.row)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Tetris::shift(int dc, int dr) {
|
||||
Piece moved = piece_;
|
||||
moved.col = static_cast<int8_t>(moved.col + dc);
|
||||
moved.row = static_cast<int8_t>(moved.row + dr);
|
||||
if (!fits(moved)) return false;
|
||||
piece_ = moved;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Tetris::rotate(int by) {
|
||||
Piece turned = piece_;
|
||||
turned.turn = static_cast<uint8_t>((turned.turn + 4 + by) & 3);
|
||||
for (auto& kick : kKicks) {
|
||||
Piece tried = turned;
|
||||
tried.col = static_cast<int8_t>(tried.col + kick[0]);
|
||||
tried.row = static_cast<int8_t>(tried.row + kick[1]);
|
||||
if (fits(tried)) {
|
||||
piece_ = tried;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The piece comes in at the top, in the middle. If there is no room for it, the game is over.
|
||||
uint8_t Tetris::spawn(uint8_t kind) {
|
||||
piece_ = Piece{kind, 0, 3, static_cast<int8_t>(kind == 1 ? -1 : 0)};
|
||||
fallSince_ = 0;
|
||||
if (fits(piece_)) return 0;
|
||||
over_ = true;
|
||||
return Over;
|
||||
}
|
||||
|
||||
void Tetris::give(uint8_t kind) { spawn(kind); }
|
||||
|
||||
uint8_t Tetris::lock() {
|
||||
uint8_t events = Locked;
|
||||
for (const Cell& c : cells(piece_)) {
|
||||
if (c.row < 0) { // it came to rest above the well
|
||||
over_ = true;
|
||||
return static_cast<uint8_t>(events | Over);
|
||||
}
|
||||
well_[static_cast<size_t>(c.row * kCols + c.col)] = piece_.kind;
|
||||
}
|
||||
// Full rows go, and what is above comes down.
|
||||
int cleared = 0, before = level();
|
||||
for (int row = kRows - 1; row >= 0;) {
|
||||
bool full = true;
|
||||
for (int col = 0; col < kCols && full; col++) full = cell(col, row) != 0;
|
||||
if (!full) {
|
||||
row--;
|
||||
continue;
|
||||
}
|
||||
for (int r = row; r > 0; r--)
|
||||
for (int col = 0; col < kCols; col++) well_[static_cast<size_t>(r * kCols + col)] = cell(col, r - 1);
|
||||
for (int col = 0; col < kCols; col++) well_[static_cast<size_t>(col)] = 0;
|
||||
cleared++; // the same row again: it now holds the one that was above
|
||||
}
|
||||
lastCleared_ = cleared;
|
||||
if (cleared) {
|
||||
score_ += kLineScore[cleared] * (before + 1);
|
||||
lines_ += cleared;
|
||||
events |= Lines;
|
||||
if (level() > before) events |= LevelUp;
|
||||
}
|
||||
uint8_t coming = next_;
|
||||
next_ = deal();
|
||||
return static_cast<uint8_t>(events | spawn(coming));
|
||||
}
|
||||
|
||||
uint8_t Tetris::step(uint32_t ms, const TetrisInput& in) {
|
||||
if (over_) return 0;
|
||||
uint8_t events = 0;
|
||||
ms = std::min<uint32_t>(ms, 1000); // a held-up loop is not a piece through the floor
|
||||
|
||||
if (in.rotate && rotate(1)) events |= Turned;
|
||||
if (in.rotateBack && rotate(-1)) events |= Turned;
|
||||
|
||||
// Left and right: once at the press, then again and again while held.
|
||||
int dir = in.left == in.right ? 0 : in.left ? -1 : 1;
|
||||
if (dir != heldDir_) {
|
||||
heldDir_ = dir;
|
||||
heldMs_ = 0;
|
||||
repeating_ = false;
|
||||
if (dir && shift(dir, 0)) events |= Moved;
|
||||
} else if (dir) {
|
||||
heldMs_ += ms;
|
||||
uint32_t wait = repeating_ ? kRepeatEveryMs : kRepeatAfterMs;
|
||||
while (heldMs_ >= wait) {
|
||||
heldMs_ -= wait;
|
||||
repeating_ = true;
|
||||
wait = kRepeatEveryMs;
|
||||
if (shift(dir, 0)) events |= Moved;
|
||||
}
|
||||
}
|
||||
|
||||
if (in.hardDrop) {
|
||||
int rows = 0;
|
||||
while (shift(0, 1)) rows++;
|
||||
score_ += 2 * rows;
|
||||
return static_cast<uint8_t>(events | lock());
|
||||
}
|
||||
|
||||
// Falling faster is for the piece it was asked for: the key still held when that one lands
|
||||
// does not send the next one down too. It has to be let go and pressed again.
|
||||
if (!in.soft) softSpent_ = false;
|
||||
bool soft = in.soft && !softSpent_;
|
||||
uint32_t every = soft ? std::min(fallMs(), kSoftMs) : fallMs();
|
||||
fallSince_ += ms;
|
||||
while (!over_ && fallSince_ >= every) {
|
||||
fallSince_ -= every;
|
||||
if (shift(0, 1)) {
|
||||
if (soft) score_++;
|
||||
events |= Moved;
|
||||
} else {
|
||||
events |= lock();
|
||||
softSpent_ = in.soft;
|
||||
break; // the next piece starts its own fall
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "game_rng.h"
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
// What the player asks of Tetris in one step: the first three for as long as the key is held,
|
||||
// the others once for each press.
|
||||
struct TetrisInput {
|
||||
bool left = false, right = false, soft = false;
|
||||
bool rotate = false, rotateBack = false, hardDrop = false;
|
||||
};
|
||||
|
||||
// Tetris (issue #101): a well of 10 x 20, the seven pieces dealt from a bag (each once in every
|
||||
// seven), the next one shown, a turn that shifts away from a wall, a soft and a hard drop, and a
|
||||
// fall that quickens every ten lines. No hold, no ghost. Time comes in milliseconds.
|
||||
class Tetris {
|
||||
public:
|
||||
static constexpr int kCols = 10, kRows = 20;
|
||||
static constexpr uint32_t kSlowestMs = 800, kFastestMs = 80;
|
||||
static constexpr uint32_t kSoftMs = 90; // a row, falling faster: quick, and still steered
|
||||
static constexpr uint32_t kRepeatAfterMs = 170, kRepeatEveryMs = 50; // a held left or right
|
||||
static constexpr int kLinesALevel = 10;
|
||||
|
||||
enum Event : uint8_t { Moved = 1, Turned = 2, Locked = 4, Lines = 8, LevelUp = 16, Over = 32 };
|
||||
// The pieces, 1 to 7, in the order I O T S Z J L; 0 is an empty cell.
|
||||
struct Piece {
|
||||
uint8_t kind = 0;
|
||||
uint8_t turn = 0; // 0 to 3, quarter turns clockwise
|
||||
int8_t col = 0, row = 0; // of the top left of its 4 x 4 box; the row may be above the well
|
||||
};
|
||||
struct Cell {
|
||||
int8_t col, row;
|
||||
};
|
||||
|
||||
explicit Tetris(uint32_t seed) : rng_(seed) { start(); }
|
||||
void start();
|
||||
uint8_t step(uint32_t ms, const TetrisInput& in);
|
||||
|
||||
bool over() const { return over_; }
|
||||
uint8_t cell(int col, int row) const { return well_[static_cast<size_t>(row * kCols + col)]; }
|
||||
const Piece& piece() const { return piece_; }
|
||||
uint8_t next() const { return next_; }
|
||||
int score() const { return score_; }
|
||||
int lines() const { return lines_; }
|
||||
int level() const { return lines_ / kLinesALevel; }
|
||||
uint32_t fallMs() const; // between two rows of fall, at this level
|
||||
int lastCleared() const { return lastCleared_; } // by the last piece locked
|
||||
|
||||
// The four cells a piece covers.
|
||||
static std::array<Cell, 4> cells(const Piece& p);
|
||||
// For the tests: a cell of the well set, and the piece in play replaced.
|
||||
void put(int col, int row, uint8_t kind) { well_[static_cast<size_t>(row * kCols + col)] = kind; }
|
||||
void give(uint8_t kind);
|
||||
|
||||
private:
|
||||
bool fits(const Piece& p) const;
|
||||
bool shift(int dc, int dr);
|
||||
bool rotate(int by);
|
||||
uint8_t lock();
|
||||
uint8_t spawn(uint8_t kind);
|
||||
uint8_t deal();
|
||||
|
||||
Rng rng_;
|
||||
std::array<uint8_t, kCols * kRows> well_{};
|
||||
Piece piece_;
|
||||
uint8_t next_ = 1;
|
||||
uint8_t bag_[7] = {};
|
||||
int bagLeft_ = 0;
|
||||
int score_ = 0, lines_ = 0, lastCleared_ = 0;
|
||||
uint32_t fallSince_ = 0, heldMs_ = 0;
|
||||
int heldDir_ = 0;
|
||||
bool repeating_ = false, over_ = false;
|
||||
bool softSpent_ = false; // the key that brought the last piece down is still held
|
||||
};
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -0,0 +1,101 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace roro::games {
|
||||
|
||||
// A tune as the speaker can play one: a note at a time, each so many eighths long. hz 0 is a rest.
|
||||
struct Note {
|
||||
uint16_t hz;
|
||||
uint8_t eighths;
|
||||
};
|
||||
|
||||
// Plays a tune round and round as time passes (issue #101). It makes no sound itself: step()
|
||||
// says when the next note begins, and the caller sounds it.
|
||||
class TunePlayer {
|
||||
public:
|
||||
// `at(i)` is the tune's i-th note, of `count`.
|
||||
TunePlayer(Note (*at)(size_t), size_t count, uint32_t eighthMs) : note_(at), count_(count), eighthMs_(eighthMs) {}
|
||||
|
||||
void restart() {
|
||||
at_ = 0;
|
||||
leftMs_ = 0;
|
||||
begun_ = false;
|
||||
}
|
||||
// `ms` later: true if a note begins now, with the note and how long it lasts. After a long
|
||||
// gap the notes that would have gone by are skipped, not played in a burst.
|
||||
bool step(uint32_t ms, Note& note, uint32_t& lengthMs) {
|
||||
if (!count_) return false;
|
||||
if (!begun_) {
|
||||
begun_ = true;
|
||||
ms = 0; // the time before the first note is nobody's
|
||||
} else if (ms < leftMs_) {
|
||||
leftMs_ -= ms;
|
||||
return false;
|
||||
} else {
|
||||
ms -= leftMs_;
|
||||
at_ = (at_ + 1) % count_;
|
||||
while (ms >= note_(at_).eighths * eighthMs_) { // whole notes missed
|
||||
ms -= note_(at_).eighths * eighthMs_;
|
||||
at_ = (at_ + 1) % count_;
|
||||
}
|
||||
}
|
||||
note = note_(at_);
|
||||
lengthMs = note.eighths * eighthMs_;
|
||||
leftMs_ = lengthMs - ms;
|
||||
return true;
|
||||
}
|
||||
size_t position() const { return at_; }
|
||||
// A new pace, from the next note on.
|
||||
void setEighthMs(uint32_t ms) { eighthMs_ = ms; }
|
||||
uint32_t eighthMs() const { return eighthMs_; }
|
||||
|
||||
private:
|
||||
Note (*note_)(size_t);
|
||||
size_t count_;
|
||||
uint32_t eighthMs_;
|
||||
size_t at_ = 0;
|
||||
uint32_t leftMs_ = 0;
|
||||
bool begun_ = false;
|
||||
};
|
||||
|
||||
// Korobeiniki, the Russian folk song (1861) that Tetris is played to: the tune twice, then its
|
||||
// slow second part.
|
||||
namespace korobeiniki {
|
||||
constexpr uint16_t E4 = 330, Gs4 = 415, A4 = 440, B4 = 494, C5 = 523, D5 = 587, E5 = 659, F5 = 698, G5 = 784, Gs5 = 831, A5 = 880;
|
||||
// clang-format off
|
||||
constexpr Note kFirst[] = {
|
||||
{E5, 2}, {B4, 1}, {C5, 1}, {D5, 2}, {C5, 1}, {B4, 1},
|
||||
{A4, 2}, {A4, 1}, {C5, 1}, {E5, 2}, {D5, 1}, {C5, 1},
|
||||
{B4, 3}, {C5, 1}, {D5, 2}, {E5, 2},
|
||||
{C5, 2}, {A4, 2}, {A4, 2}, {0, 2},
|
||||
{0, 1}, {D5, 2}, {F5, 1}, {A5, 2}, {G5, 1}, {F5, 1},
|
||||
{E5, 3}, {C5, 1}, {E5, 2}, {D5, 1}, {C5, 1},
|
||||
{B4, 2}, {B4, 1}, {C5, 1}, {D5, 2}, {E5, 2},
|
||||
{C5, 2}, {A4, 2}, {A4, 2}, {0, 2},
|
||||
};
|
||||
constexpr Note kSecond[] = {
|
||||
{E5, 4}, {C5, 4}, {D5, 4}, {B4, 4}, {C5, 4}, {A4, 4}, {Gs4, 4}, {B4, 2}, {0, 2},
|
||||
{E5, 4}, {C5, 4}, {D5, 4}, {B4, 4}, {C5, 2}, {E5, 2}, {A5, 4}, {Gs5, 4}, {0, 4},
|
||||
};
|
||||
// clang-format on
|
||||
constexpr size_t kFirstCount = sizeof(kFirst) / sizeof(kFirst[0]), kSecondCount = sizeof(kSecond) / sizeof(kSecond[0]);
|
||||
constexpr size_t kCount = 2 * kFirstCount + kSecondCount;
|
||||
constexpr uint32_t kEighthMs = 200; // 150 quarter notes a minute, at the first level
|
||||
constexpr uint32_t kFastestEighthMs = 110; // 272: as fast as it is still a tune
|
||||
constexpr uint32_t kFasterMs = 8; // off each eighth, a level
|
||||
// The tune hurries as the game does: its pace at a level of Tetris, the first being 0.
|
||||
constexpr uint32_t eighthMsAt(int level) {
|
||||
return level <= 0 ? kEighthMs
|
||||
: static_cast<uint32_t>(level) * kFasterMs >= kEighthMs - kFastestEighthMs ? kFastestEighthMs
|
||||
: kEighthMs - static_cast<uint32_t>(level) * kFasterMs;
|
||||
}
|
||||
|
||||
// The whole of it, in the order it is played.
|
||||
constexpr Note at(size_t i) {
|
||||
return i < kFirstCount ? kFirst[i] : i < 2 * kFirstCount ? kFirst[i - kFirstCount] : kSecond[i - 2 * kFirstCount];
|
||||
}
|
||||
} // namespace korobeiniki
|
||||
|
||||
} // namespace roro::games
|
||||
@@ -54,6 +54,24 @@ KeyEvent keyEvent(Key key, const RawKeys& keys) {
|
||||
|
||||
} // namespace
|
||||
|
||||
bool KeyMapper::heldChar(char c) const {
|
||||
return std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end();
|
||||
}
|
||||
|
||||
bool KeyMapper::held(Key key) const {
|
||||
bool arrows = previous_.fn || !textEntry_;
|
||||
switch (key) {
|
||||
case Key::Up: return arrows && heldChar(';');
|
||||
case Key::Down: return arrows && heldChar('.');
|
||||
case Key::Left: return arrows && heldChar(',');
|
||||
case Key::Right: return arrows && heldChar('/');
|
||||
case Key::Select: return previous_.enter;
|
||||
case Key::Delete: return previous_.del;
|
||||
case Key::Tab: return previous_.tab;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
||||
std::vector<KeyEvent> out;
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ class KeyMapper {
|
||||
// don't need Fn.
|
||||
void setTextEntry(bool active) { textEntry_ = active; }
|
||||
|
||||
// Is this key down right now, as of the last update()? Events only say when a key goes down:
|
||||
// a Game that moves something for as long as a key is held asks this (issue #101). The arrows
|
||||
// are ; . , / as for events: alone outside Text Entry, with Fn anywhere.
|
||||
bool held(Key key) const;
|
||||
bool heldChar(char c) const; // a character key, as it types
|
||||
|
||||
// 0 when idle, '*' when opt was pressed, or the accent character waiting for its letter.
|
||||
char pendingCompose() const { return compose_; }
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace roro::release {
|
||||
|
||||
// Installing a release without being asked to (issue #52): once a newer one is known, wait for a
|
||||
// quiet moment (nothing recording, nothing written, no key for a while), then say so on the
|
||||
// screen for 30 seconds. Unless someone answers no, it is installed. Host-tested; the caller says
|
||||
// what "quiet" is and does the installing.
|
||||
class AutoUpdate {
|
||||
public:
|
||||
static constexpr uint32_t kIdleMs = 120000; // no key for this long: nobody is using the device
|
||||
static constexpr uint32_t kAskMs = 30000; // how long the question stays before it answers itself
|
||||
|
||||
enum class Action : uint8_t {
|
||||
None,
|
||||
Ask, // the question goes up
|
||||
Dismiss, // the question comes down, nothing is installed
|
||||
Install, // the question comes down, install tag()
|
||||
};
|
||||
|
||||
// A newer release is known. A second offer while one is waiting or asked replaces its tag.
|
||||
void offer(const std::string& tag) {
|
||||
tag_ = tag;
|
||||
if (state_ == State::Idle) state_ = State::Waiting;
|
||||
}
|
||||
// The setting went off, or the release is no longer wanted.
|
||||
void withdraw() { withdrawn_ = true; }
|
||||
// The user's answer to the question.
|
||||
void answer(bool install) {
|
||||
if (state_ != State::Asking) return;
|
||||
answered_ = true;
|
||||
accepted_ = install;
|
||||
}
|
||||
|
||||
// Call often. `busy`: something is recording or being written, and a restart would cut it.
|
||||
// `idleMs`: how long since the last key.
|
||||
Action step(uint32_t nowMs, bool busy, uint32_t idleMs) {
|
||||
bool withdrawn = withdrawn_;
|
||||
withdrawn_ = false;
|
||||
switch (state_) {
|
||||
case State::Idle: return Action::None;
|
||||
case State::Waiting:
|
||||
if (withdrawn) return state_ = State::Idle, Action::None;
|
||||
if (busy || idleMs < kIdleMs) return Action::None;
|
||||
state_ = State::Asking;
|
||||
askedMs_ = nowMs;
|
||||
answered_ = false;
|
||||
return Action::Ask;
|
||||
case State::Asking:
|
||||
if (withdrawn) return state_ = State::Idle, Action::Dismiss;
|
||||
if (answered_) return state_ = State::Idle, accepted_ ? Action::Install : Action::Dismiss;
|
||||
// Something started meanwhile (a Track from a console): the question goes, and comes back later.
|
||||
if (busy) return state_ = State::Waiting, Action::Dismiss;
|
||||
if (nowMs - askedMs_ >= kAskMs) return state_ = State::Idle, Action::Install;
|
||||
return Action::None;
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
|
||||
bool waiting() const { return state_ == State::Waiting; }
|
||||
bool asking() const { return state_ == State::Asking; }
|
||||
const std::string& tag() const { return tag_; }
|
||||
// Whole seconds until the question answers itself, rounded up.
|
||||
int secondsLeft(uint32_t nowMs) const {
|
||||
uint32_t gone = nowMs - askedMs_;
|
||||
return !asking() || gone >= kAskMs ? 0 : static_cast<int>((kAskMs - gone + 999) / 1000);
|
||||
}
|
||||
|
||||
private:
|
||||
enum class State : uint8_t { Idle, Waiting, Asking };
|
||||
State state_ = State::Idle;
|
||||
std::string tag_;
|
||||
uint32_t askedMs_ = 0;
|
||||
bool answered_ = false, accepted_ = false, withdrawn_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro::release
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "http_head.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
@@ -58,6 +60,15 @@ void HttpHeadParser::line() {
|
||||
else if (name == "transfer-encoding") head_.chunked = lower(value).find("chunked") != std::string::npos;
|
||||
else if (name == "location") head_.location = value;
|
||||
else if (name == "content-type") head_.contentType = value;
|
||||
else if (name == "etag") head_.etag = value;
|
||||
else if (name == "content-range") {
|
||||
long from = -1, to = -1, total = -1;
|
||||
if (std::sscanf(lower(value).c_str(), "bytes %ld-%ld/%ld", &from, &to, &total) == 3 && from >= 0 && to >= from && total > to) {
|
||||
head_.rangeFrom = from;
|
||||
head_.rangeTo = to;
|
||||
head_.rangeTotal = total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size_t ChunkedDecoder::decode(const uint8_t* in, size_t len, uint8_t* out) {
|
||||
|
||||
@@ -12,6 +12,9 @@ struct HttpHead {
|
||||
long contentLength = -1; // -1: not given
|
||||
bool chunked = false;
|
||||
std::string location, contentType;
|
||||
std::string etag; // as sent, quotes included; "" if none
|
||||
// "Content-Range: bytes 1000-2999/3000", the answer to a range request; -1: not given.
|
||||
long rangeFrom = -1, rangeTo = -1, rangeTotal = -1;
|
||||
};
|
||||
|
||||
class HttpHeadParser {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "http_head.h"
|
||||
|
||||
namespace roro::release {
|
||||
|
||||
// Carrying on a download that broke (issue #54): the connection is opened again with
|
||||
// "Range: bytes=<from>-", and what was already received stays where it is. The Update File's own
|
||||
// hash and signature are what decide whether the whole is good; these checks only keep the device
|
||||
// from carrying on with something that plainly is not the rest of the same file.
|
||||
|
||||
// The validator to send as "If-Range", so a file that changed comes back whole (a 200) instead of
|
||||
// in part: only a strong ETag may be used for that. "" for none.
|
||||
inline std::string ifRangeFor(const std::string& etag) {
|
||||
return etag.size() >= 2 && etag.front() == '"' && etag.back() == '"' ? etag : "";
|
||||
}
|
||||
|
||||
// Is this answer the rest of the file, from byte `from` of `total`? "" if so, or why not, in
|
||||
// words for the screen. `etag` is what the first answer gave ("" if nothing).
|
||||
inline std::string resumeRefusal(const HttpHead& head, long from, long total, const std::string& etag) {
|
||||
if (head.status == 200) return "The file changed on the server";
|
||||
if (head.status != 206) return "The server answered " + std::to_string(head.status);
|
||||
if (head.chunked || head.rangeFrom != from) return "The server sent another part of the file";
|
||||
if (head.rangeTotal != total || head.rangeTo != total - 1 || head.contentLength != total - from) return "The file changed size on the server";
|
||||
if (!etag.empty() && !head.etag.empty() && head.etag != etag) return "The file changed on the server";
|
||||
return "";
|
||||
}
|
||||
|
||||
// How long to keep trying. A break that came after new bytes is the link being poor, and is
|
||||
// worth another try; breaks with nothing in between mean it is not coming back.
|
||||
class ResumePolicy {
|
||||
public:
|
||||
static constexpr int kMaxResumes = 20; // in one download
|
||||
static constexpr int kMaxStuck = 3; // in a row without a byte
|
||||
|
||||
// The connection broke with `received` bytes of the file here: try again?
|
||||
bool again(long received) {
|
||||
stuck_ = received > last_ ? 0 : stuck_ + 1;
|
||||
last_ = received;
|
||||
if (resumes_ >= kMaxResumes || stuck_ >= kMaxStuck) return false;
|
||||
resumes_++;
|
||||
return true;
|
||||
}
|
||||
int resumes() const { return resumes_; }
|
||||
|
||||
private:
|
||||
long last_ = 0;
|
||||
int resumes_ = 0, stuck_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro::release
|
||||
@@ -24,6 +24,12 @@ inline bool shouldAnnounce(const Release& latest, const std::string& running, co
|
||||
return isNewer(latest, running) && latest.tag != failed && latest.tag != announced;
|
||||
}
|
||||
|
||||
// Should the background check offer to install it by itself (issue #52)? Any newer release, each
|
||||
// day it is still the newest; never a version that rolled back on this device.
|
||||
inline bool shouldInstall(const Release& latest, const std::string& running, const std::string& failed) {
|
||||
return isNewer(latest, running) && latest.tag != failed;
|
||||
}
|
||||
|
||||
// Is `url` a download this device takes from the project's server, for this repository? Whatever
|
||||
// the API says, the device only fetches from where it asked (a redirected or rewritten answer
|
||||
// can't send it elsewhere).
|
||||
|
||||
@@ -56,6 +56,7 @@ const Definition kDefinitions[] = {
|
||||
{"launcher_list", Kind::Bool, 0, nullptr, 0, 1}, // off: the grid
|
||||
{"theme", Kind::Int, 0, nullptr, 0, 6}, // roro9stack's own; ui::kThemeCount of them
|
||||
{"theme_light", Kind::Bool, 0, nullptr, 0, 1},
|
||||
{"auto_install", Kind::Bool, 0, nullptr, 0, 1}, // off: what runs here changes only when someone says so
|
||||
};
|
||||
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
|
||||
@@ -44,6 +44,7 @@ enum class Setting : uint8_t {
|
||||
LauncherList, // bool: the Launcher is a list of names instead of a grid of icons (issue #9)
|
||||
Theme, // int: the colour theme, an index into ui::palette (issue #10)
|
||||
ThemeLight, // bool: its light side instead of its dark one
|
||||
InstallUpdates, // bool: a newer release found by the daily check is installed without being asked for (issue #52)
|
||||
Count
|
||||
};
|
||||
|
||||
|
||||
@@ -46,9 +46,10 @@ To install from the SD card instead, copy the `.ota` file from `.pio/build/cardp
|
||||
|
||||
With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**:
|
||||
|
||||
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update.
|
||||
- **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. A download that breaks is carried on from where it was (issue #54): the device waits up to a minute for Wi-Fi, asks for the rest of the file, and gives up only after three tries in a row that bring nothing.
|
||||
- **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question.
|
||||
- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device.
|
||||
- **Settings → System → Install updates** (`When asked` by default; `By itself`, issue #52): a newer release found by the daily check is installed without being asked for. The device waits until no key was pressed for 2 minutes and nothing would be cut by a restart (a Track, a Capture, a copy, an upload, an SSH session, a web share), then asks on the screen for 30 seconds, lighting it if it was off: Cancel leaves it until the next day's check; Accept, or no answer, installs it and restarts. What was installed and when, and what was undone, is written in `/system/updates.log` on the card.
|
||||
|
||||
The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed.
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
|
||||
| `cp [-f] <from> <to>` / `mv [-f] <from> <to>` / `rm [-r] [-f] <path>` / `mkdir <path>` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete |
|
||||
| `install <path>` | Update from SD with that `.ota` file, as Settings → System → Firmware does |
|
||||
| `update check` / `update list` / `update status` / `update install <tag>` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again |
|
||||
| `update pretend <version>` / `update probe <host>` / `update damage cut\|flip <n>` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, break each connection of the next download after n bytes (it is carried on, issue #54) or damage one byte of it, run the daily check again |
|
||||
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
|
||||
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
|
||||
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
|
||||
|
||||
@@ -83,7 +83,7 @@ An update that goes wrong is the case you most want to rehearse, and the firmwar
|
||||
update status # what the device runs, what failed here before, the daily check, heap
|
||||
update check | update list # look at the server: the latest release, or the last ten
|
||||
update pretend v0.9.0 # take the running version to be v0.9.0: the latest release now counts as "new"
|
||||
update damage cut 50000 # the next download is cut after 50000 bytes
|
||||
update damage cut 800000 # each connection of the next download breaks after 800000 bytes
|
||||
update damage flip 100000 # ...or has the byte at offset 100000 damaged
|
||||
update install v0.11.0 # try the install
|
||||
update probe git.twis.la # is that server's certificate accepted? (the two ISRG roots only)
|
||||
@@ -91,7 +91,8 @@ update daily # forget today's daily check: it runs again
|
||||
update pretend off # back to the real version
|
||||
```
|
||||
|
||||
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a cut or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
|
||||
- **A download that breaks is carried on:** with `damage cut 800000` a 2.5 MB release comes in four connections, each asking for the rest of the file, and installs; `update status` then says how often it was carried on. With `damage cut 0` no connection brings anything, and it gives up after three tries.
|
||||
- **A damaged download must be refused cleanly:** the Update Service checks the signature after the first 160 bytes and the image hash at the end, so a download that gave up or a flipped byte must end in a refusal with **nothing switched**. Check `info` afterwards: both slots, and `update: confirmed`.
|
||||
- **An undamaged install really installs** the release, into the other slot, and the device restarts into it. Your build stays in the slot it was in until the next update overwrites it, and the console's setting and token are untouched: the release has the console too.
|
||||
- The server is read by the device itself, with Wi-Fi up; the download is one TLS connection, about 52 KB of heap at its peak, so IRC steps aside (see [the memory limit](/howto/not-enough-memory/)). `status: heap` in the stream shows it happen.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ docs = true
|
||||
source = "docs/milestones/R1.md"
|
||||
tag = "R1"
|
||||
+++
|
||||
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Not started: the Issues App (#4), automatic installs (#52), release channels (#53), resuming a download (#54).
|
||||
**Status:** in progress. Shipped: CI and signed releases on Gitea (issue #5), a release for every tag; updates from Gitea (#6, **v0.11.0**); one firmware with the Debug Console in it (#68, **v0.12.0**); CI in about a minute (#74, **v0.13.0**). Built, not released yet: installing a release by itself (#52). Built, not released yet: carrying on a download that broke (#54). Not started: the Issues App (#4), release channels (#53).
|
||||
|
||||
**Goal:** a tag is a release, built the same way every time and published where a device can find it.
|
||||
|
||||
@@ -132,6 +132,69 @@ The device looks at the project's Gitea for a newer release, says so, and instal
|
||||
|
||||
**Two slips during the checks:** a blind sequence of keys on the Firmware page opened the SD card's install dialog (the page keeps its selection between visits); it was cancelled with Back, nothing installed. And my port-polling while waiting for a restart took the Debug Console's only client slot, which made the first install attempt look like a failure.
|
||||
|
||||
## Installing a release by itself (issue #52)
|
||||
|
||||
For a device that sits on a desk, "always on the latest" may be what is wanted. Until now the daily check only said that a release was out.
|
||||
|
||||
### Decisions (2026-10-11)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q275 | **A setting, off by default:** Settings > System > Install updates, `When asked` or `By itself`. It changes what runs on the device without anyone looking, so it is never on unless the owner said so. |
|
||||
| Q276 | **The owner is asked, and silence is yes:** a question over whatever is open, `Cancel` or `Accept`, that answers itself after 30 seconds by installing. The screen lights up for it if it was off. (The owner's own answer on the issue.) |
|
||||
| Q277 | **The question comes before the download,** not before the restart: the download already takes the screen over and may take IRC offline for its memory, so that is the moment to ask. |
|
||||
| Q278 | **Only in a quiet moment:** no key for 2 minutes, and nothing a restart would cut: a Track, a Capture, a file operation, an upload, an scp transfer, an SSH session, a web share, a noise test, another update. If one of them starts while the question is up, the question goes and comes back later with a full count. |
|
||||
| Q279 | **Any newer release,** not only patch releases, and with no delay after it is published: Probation and Rollback are what protect against a bad one, and a version that rolled back here is never offered again. |
|
||||
| Q280 | **Cancel lasts a day:** the release is offered again at the next daily check, for as long as it is the newest. |
|
||||
| Q281 | **A record:** each update that held (and whether it installed by itself), and each one that was undone, is a dated line in `/system/updates.log` on the card. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/release/src/auto_update.h`**, host-tested (10 tests): the waiting, the question and its count, as a state machine that is told what "busy" and "idle" are.
|
||||
- **The Update Service** offers the release at each background check when the setting is on (`shouldInstall`), and writes the record when Probation confirms a version or a rollback is reported.
|
||||
- **The main loop** decides what busy is, puts the question up through `Screen::render`, and gives it every key while it is up.
|
||||
- `update status` says whether a release is waiting for its quiet moment.
|
||||
|
||||
### Checked on the device (2026-10-11)
|
||||
|
||||
With the setting on, `update pretend v0.22.0` and `update daily`:
|
||||
|
||||
- The question came up over the Settings App, counting down each second. **Cancel** took it down and nothing was installed.
|
||||
- Left unanswered, the device downloaded v0.23.0, installed it and restarted into it.
|
||||
- `/system/updates.log` got its line at each confirmed update.
|
||||
- Not checked: "by itself" in the record (the release installed did not have this code yet), the 2 minutes without a key and the busy cases (host tests only), and a screen that was off lighting up. Keys sent through the Debug Console do not count as keys.
|
||||
|
||||
## Carrying on a download that broke (issue #54)
|
||||
|
||||
A release goes straight into the inactive slot (Q167), so a connection that broke meant starting again, and on a weak link a 2.5 MB file might never arrive whole.
|
||||
|
||||
### What the server gives (checked 2026-10-11)
|
||||
|
||||
A request with `Range: bytes=0-0` for a release's file gets `206`, `Content-Range: bytes 0-0/2510384` and a strong `ETag`.
|
||||
|
||||
### Decisions (2026-10-11)
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| Q282 | **Carried on within the same install, in memory only.** The slot's write handle and the parser, with its hash so far, stay as they are while the connection is opened again with `Range: bytes=<where it was>-`. The parser never sees the break. |
|
||||
| Q283 | **Nothing is kept across a restart** (the issue's second question): the slot cannot be reopened at an offset, so a download cut by a power-off starts over. |
|
||||
| Q284 | **The answer must be the rest of the same file:** a `206` from that byte to the end, of the same total size, with the same `ETag` if both answers have one. The `ETag` goes in `If-Range`, so a file that changed comes back whole (a `200`), which is refused. What decides whether the whole is good is still the Update File's own signature and hash. |
|
||||
| Q285 | **How long:** before each try, up to a minute for Wi-Fi to come back. Tries go on while each brings more bytes, 20 at most in one download; three in a row that bring nothing end it. |
|
||||
| Q286 | **`update damage cut <n>`** now breaks each connection of the next download after n bytes, so both the carrying on and the giving up can be tried. |
|
||||
|
||||
### As built
|
||||
|
||||
- **`lib/release/src/resume.h`**, host-tested (5 tests with the head's new fields): `resumeRefusal`, `ifRangeFor`, `ResumePolicy`. `HttpHead` reads `ETag` and `Content-Range`.
|
||||
- **`HttpsGet::openFrom`** asks for a range; **`GiteaSource`** in the Update Service does the waiting and the trying inside its `read()`.
|
||||
- `update status` says how often the last download was carried on.
|
||||
|
||||
### Checked on the device (2026-10-11)
|
||||
|
||||
- **`update damage cut 800000`, then an install of v0.23.0** (2 510 384 bytes): it arrived over four connections, its hash matched, and the device restarted into it.
|
||||
- **`update damage cut 0`:** three connections that brought nothing, then it gave up (`carried on 2 time(s)`), with the running slot still the one to boot.
|
||||
- **Memory decided the first attempt.** Between two connections 78 KB was free, under the 80 KB a TLS connection asks for before it starts, so every try was refused and the download gave up. The install's own buffers (about 8 KB) are what is missing, and nothing else asks for memory while the screen is taken over: carrying on asks for 64 KB (the 52 KB peak and 12 KB). The lowest the heap went during the broken download was 20 KB, with a console client connected.
|
||||
- Not checked: a real Wi-Fi drop in the middle (the wait for Wi-Fi to come back), and a file that changed on the server.
|
||||
|
||||
## One firmware: the Debug Console in every build (issue #68)
|
||||
|
||||
The issue asked for a token that could be set, so that Debug Builds could be published. The design round ended somewhere simpler: no Debug Builds. The console is in every firmware, off until switched on, with a token that belongs to the device (ADR 0010, which supersedes part of ADR 0004).
|
||||
|
||||
@@ -138,7 +138,7 @@ Settings had grown to 21 rows in one list. It now opens on five groups, each a s
|
||||
| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher |
|
||||
| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs |
|
||||
| Network | Wi-Fi, VPN |
|
||||
| System | Check for updates, Firmware, Debug Console, About |
|
||||
| System | Check for updates, Install updates, Firmware, Debug Console, About |
|
||||
|
||||
- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows.
|
||||
- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name.
|
||||
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,215 @@
|
||||
+++
|
||||
title = '''Not finished until it plays Tetris'''
|
||||
description = '''roro9stack had a radio scanner, an SSH client and a VPN, and no games, which for a firmware means it wasn't finished. Now it has Pong, Snake and Tetris, with the tune, in 14.6 KB of flash. Seventeen design questions were answered with one word each, and then an evening of playing overturned three of the answers.'''
|
||||
date = 2026-10-13T23:30:00+02:00
|
||||
|
||||
[extra]
|
||||
topics = '''ESP32-S3 · Games · Chiptune'''
|
||||
read_label = '''Read how three games got in →'''
|
||||
uid = '''<b>tetris:</b> level 1, music 6/8'''
|
||||
dek = "[roro9stack](/devlog/roro9stack/), my firmware for the M5Stack Cardputer, could sniff a LoRa mesh, browse Gemini and open a shell on a server. It could not play Snake. That is fixed: a Games App with Pong, Snake and Tetris, and the tune Tetris has always been played to. This post is about why a firmware needs them, what three very old games still do to people, and how little room fun takes."
|
||||
byline = '''seventeen questions answered "Ok", then eight things changed by playing'''
|
||||
|
||||
[extra.sign]
|
||||
label = "Bytes of flash for three games and a folk song"
|
||||
note = "The Tetris cartridge for the Game Boy was 32,768."
|
||||
count = "14,616"
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The Launcher"
|
||||
role = "a grid of twelve tiles"
|
||||
text = "Had eleven Apps in it. Three games would have made fourteen, so there is one Games App, and the grid is full."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The key mapper"
|
||||
role = "turns the keyboard into events"
|
||||
text = "Told Apps when a key went down and never that it was still down. Fine for a menu. Useless for a paddle."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "Korobeiniki"
|
||||
role = "a Russian folk song, 1861"
|
||||
text = "The tune of Tetris. Fifty-eight notes in 232 bytes. I had decided there would be no music, and held to that until Tetris was running."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "The 1 key"
|
||||
role = "top left of the keyboard"
|
||||
text = "Drops the piece. It was going to be Space, which on this keyboard sits right next to the arrow for 'right'. A drop cannot be taken back."
|
||||
|
||||
[[extra.cast]]
|
||||
name = "A script"
|
||||
role = "playing over Wi-Fi"
|
||||
text = "Presses keys through the Debug Console and reads the screen back. Played the Snake in this post's picture, and lost 35 games in the two runs I counted."
|
||||
+++
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **A Games App with Pong, Snake and Tetris** (**v0.24.0**).
|
||||
- **It costs 14.6 KB of flash and 8 bytes of static memory.** The games allocate their few hundred bytes when one is started.
|
||||
- **Each game is a small state machine with no screen in it:** time and keys go in, the new state comes out, and a seed decides the dice. 44 tests play them on the PC.
|
||||
- **Apps can now ask which keys are held.** Until this week they only heard presses.
|
||||
- **Tetris plays Korobeiniki,** faster with each level. `0` switches it, `8` and `9` set its volume.
|
||||
- **Playing changed what the design round had decided:** icons instead of a list, the drop moved off Space, and music after all.
|
||||
- 673 host tests, 47 more than the firmware had without games.
|
||||
|
||||
## The cast
|
||||
|
||||
{{ cast() }}
|
||||
|
||||
## It wasn't finished
|
||||
|
||||
A firmware that can't play a game isn't done. I don't mean that as a joke.
|
||||
|
||||
Look at what people do the moment a machine has a screen and a few buttons. Emacs has had `M-x tetris` for decades, next to `M-x snake` and `M-x pong`. The BSDs still ship `tetris(6)` and `snake(6)` in `/usr/games`. An HP oscilloscope from the nineties, a serious instrument that cost a serious amount, hides a Tetris behind a key sequence. The Flipper Zero comes with Snake. Chrome has a dinosaur for when the network is down. Nobody needed any of these. Somebody put each of them in anyway.
|
||||
|
||||
A game is the first thing you write for yourself. Everything else in this firmware answers a need: a clock to set, a packet to decode, a file to move. A game answers nothing. It is there because the machine is yours and you wanted to play on it, and a device that has one feels owned in a way that a device with a perfect settings menu doesn't.
|
||||
|
||||
{{ figure(src="launcher.png", alt="The Cardputer's Launcher at 2x: a grid of twelve cyan icons on black, four across and three down. The tenth, a game pad, is on a filled cyan tile, and the name under the grid reads Games.", width=480, height=270, caption="The Launcher, full: twelve tiles, and the game pad is one of them.") }}
|
||||
|
||||
## What these three carry
|
||||
|
||||
**Pong** is from 1972 and had no program at all. The first machines were wired from logic chips: no processor, nothing to load. Two paddles, a square ball, a score. It is still the clearest picture there is of what a video game is.
|
||||
|
||||
**Tetris** was written in 1984 on a Soviet computer whose terminal could not draw, so the first blocks were pairs of brackets. Five years later it came in the box with the Game Boy, and with it came a folk tune from 1861 that half the planet now calls "the Tetris music".
|
||||
|
||||
**Snake** shipped on a Nokia phone in 1997, on a screen 84 pixels wide, and turned waiting rooms and bus stops into something else for a decade.
|
||||
|
||||
You don't have to have owned any of those machines for these games to pull at something. The sound of a line going, the snake one cell from its own tail, the ball coming back faster than you sent it: they are in the hands more than in the head. Nobody has to be told the rules of any of the three. That is rare enough in software to be worth an evening.
|
||||
|
||||
And they are small. That is the part I can't get over. This device has a screen of 240 by 135 pixels, somewhat more than the Game Boy's 160 by 144, and 8 MB of flash where the Game Boy's Tetris had 32 KB to itself. Three games fit here in less than half of that cartridge. They were small when memory cost a fortune, and they stayed small because there was nothing in them to grow: a field, a rule, a clock that speeds up.
|
||||
|
||||
{{ figure(src="games.png", alt="The Games App at 2x: three icons in a row on black. Two paddles and a ball; a snake with a head, on a filled cyan tile; stacked blocks over a floor, with a small orange dot at its corner. Under the row, Snake in bold, and below it, best 11.", width=480, height=270, caption="The three games, shown the way the Launcher shows Apps. The orange dot says a game of Tetris is waiting, paused. Best 11 is mine, by hand.") }}
|
||||
|
||||
## Seventeen questions, all answered "Ok"
|
||||
|
||||
The design round was seventeen questions, each with a recommended answer, and I took every recommendation. It went fast because the facts had been looked up first, and two of them shaped everything:
|
||||
|
||||
**The Launcher was full.** Its grid shows twelve Apps and eleven were there. So: one Games App holding three games. In the project's vocabulary a **Game** is not an App. It has no tile, and it is found again paused when you leave in the middle of one.
|
||||
|
||||
**Apps only heard key presses.** A key gave one event when it went down, and nothing while it stayed down. A Pong paddle moved by tapping is not Pong. The key mapper now answers "is this key held?", and three tests on the PC hold keys down to prove it.
|
||||
|
||||
The rest, briefly:
|
||||
|
||||
{% table() %}
|
||||
| | Decided |
|
||||
|---|---|
|
||||
| Pong | Against the device, first to 7. Its paddle has a top speed, so a steep fast ball beats it |
|
||||
| Snake | 38 by 19 cells. The walls kill |
|
||||
| Tetris | 10 by 20, upright, 6 pixels a cell. Pieces dealt from a bag of seven, the next one shown |
|
||||
| Difficulty | No choice. Each game starts gently and speeds up |
|
||||
| Colours | The seven pieces take the theme's seven accent colours |
|
||||
| Scores | One best for each game, kept in the device |
|
||||
| Sound | Short beeps. **No music** |
|
||||
{% end %}
|
||||
|
||||
Each game lives in `lib/games` as a state machine that knows nothing about a screen or a keyboard: so many milliseconds and these keys go in, the new state and what happened (a bounce, a line) come out. A seed decides the dice, so the same seed is the same game. That is what lets a test say "a player who always meets the ball must take points off the device over five games", and have it checked on every build without anyone playing.
|
||||
|
||||
{{ figure(src="pong.png", alt="Pong on the Cardputer at 2x: a cyan paddle on the left near the top, an orange one on the right, a dashed line down the middle, a small white ball left of centre, and the score 1 to 0 at the top.", width=480, height=270, caption="One-nil against the device. The cyan paddle is the player's.") }}
|
||||
|
||||
## Then I played it
|
||||
|
||||
A design round answers the questions you can think of from a chair. Then the thing is in your hands, and in a little over an hour I had asked for eight changes:
|
||||
|
||||
1. **A list of three games looked wrong.** The Launcher shows icons; so should this. Three icons, drawn on a 16 by 16 grid and doubled, like the Apps'. The first snake looked like the digit 2 and was redrawn with a head.
|
||||
2. **Space was dangerous.** On this keyboard it sits next to `/`, which is the arrow for "right". A hard drop can't be taken back, and one slip of a finger would do it. It is on `1` now, far from the arrows.
|
||||
3. **"No music" lasted until Tetris was running.** I changed my mind and asked for the tune. See below.
|
||||
4. **`8` and `9`** for the music's volume, once there was music.
|
||||
5. **The tune speeds up with the level,** because the fall does.
|
||||
6. **The top volume was too low.** The speaker had only ever been used for short beeps, at a quarter of what it can do.
|
||||
7. **Then it was too high,** and the scale was cut to four fifths.
|
||||
8. **The soft drop sometimes behaved like a hard drop.** That one was a bug, and is under "What went wrong".
|
||||
|
||||
None of these would have come out of more questions. They came out of having the thing in my hands.
|
||||
|
||||
{{ figure(src="tetris.png", alt="Tetris on the Cardputer at 2x: a well in the middle, with a pile of blocks in seven colours filling its lower half except the rightmost column, and a yellow square piece falling. On the left, Score 0, Best 56, Lines 0. On the right, Next with a long cyan piece, Level 1, and at the bottom, music 2/8.", width=480, height=270, caption="All seven colours, from the theme. The score is 0 because the script that played this let every piece fall by itself: a drop scores, and I didn't want a script's score kept as the best. The 56 is from a test.") }}
|
||||
|
||||
## The tune is 232 bytes
|
||||
|
||||
Korobeiniki is a song about a pedlar, from a poem of 1861, and it has been the sound of Tetris since the Game Boy. Here it is a table of 58 notes, each a frequency and a length in eighths, four bytes a note: the first part, played twice, then the slow second part. A player the size of a paragraph says when the next note starts, and the speaker's tone generator does the rest. There is no sample, no synthesiser and no file.
|
||||
|
||||
It has a speaker channel of its own, so the beep of a line cleared doesn't cut a note short. At the first level it runs at 150 beats a minute, and each level takes 8 milliseconds off every eighth, down to about 270 beats a minute from the thirteenth level on. A new pace starts at the next note, never in the middle of one. The timing has its own tests: 9.9 seconds fed in steps of 33 ms must give exactly a hundred notes, not ninety-nine and a drift.
|
||||
|
||||
`0` switches it off, and the device remembers.
|
||||
|
||||
## What went wrong
|
||||
|
||||
**The soft drop that wouldn't let go.** Holding `.` makes a piece fall faster. When it landed with the key still down, the next piece came in already falling fast, and so did the one after: a soft drop that felt like a hard one. Now the key that brought a piece down does nothing for the next piece until it is let go and pressed again. It was also simply too fast, a row every 50 ms; it is 90 now.
|
||||
|
||||
**A dead snake was a cell short.** The tail was moved before checking whether the head had hit something, so the snake on the "Game over" screen was missing its last cell. One cell, and it looked wrong at once.
|
||||
|
||||
**The pictures in this post tore.** The Debug Console's screenshot reads the frame while the App may be drawing it. For a settings page that never matters. A game redraws thirty times a second, and the first Tetris picture had a black band through the pile where a new frame was half drawn. The code has said so in a comment since the day it was written: "it can tear; it's for looking". The picture above is one of two taken back to back that agreed.
|
||||
|
||||
**A script is bad at Snake.** I wanted a picture of a snake that had eaten something, and the console can press keys, so a script read the screen and steered. It lost 24 games in under five minutes and never got longer than six. The console goes quiet for a second or two now and then, and at a cell every 150 ms that is a wall. It went quiet most often right after the beep of a meal. With the sound off it lost 11 games in two and a half minutes and reached ten cells, which is the snake below. I haven't found out why a beep and a stalled connection go together.
|
||||
|
||||
{{ figure(src="snake.png", alt="Snake on the Cardputer at 2x: a framed black field, a green snake ten cells long with a white head, folded back on itself in the middle, and an orange square of food near the bottom right corner. Above the field, the score 7 on the left and best 11 on the right.", width=480, height=270, caption="Ten cells, seven meals, played by a script over Wi-Fi, a beat late. The best of 11 it didn't touch.") }}
|
||||
|
||||
## What I checked, and what I didn't
|
||||
|
||||
Checked on the device, by playing: all three games start, pause, resume and end; the paddle follows a held key; Tetris's left and right repeat; the tune, its switch, its volume and its memory of both; the screen staying lit during a game and dimming again when one is paused.
|
||||
|
||||
Checked on the PC, by 44 tests: the rules. Bounces, the first to seven, the device's paddle having a top speed and still losing points; turning back on itself not being a turn, following its own tail being allowed; the bag dealing each piece once in seven, a turn against a wall shifting the piece, four rows at once scoring 800.
|
||||
|
||||
Not checked: Tetris past the first few levels, so nobody has heard the tune at its fastest. A long session on battery, with the screen redrawn thirty times a second. Whether the top of the volume scale distorts on every unit or only on mine. And in the Nord theme two of the seven pieces come out the same orange, because that theme's red and orange are neighbours on a screen with 256 colours.
|
||||
|
||||
## Also since last time
|
||||
|
||||
There was no post for the last three releases, and v0.24.0 has more in it than games:
|
||||
|
||||
- **An SSH client** (**v0.22.0**): a real terminal on another machine, with `vim` and `top` in it, and `scp` from the Shell (**v0.22.1**).
|
||||
- **MeshCore in the LoRa Scanner, a Launcher of icons, seven colour themes, Settings in groups** (**v0.23.0**).
|
||||
- Also in **v0.24.0**: **a compass** in the GNSS App, which has no magnetometer and takes north from walking or from the sun; **updates that install themselves** after a 30-second question, if you ask for that; and **downloads that carry on** after the Wi-Fi drops instead of starting over.
|
||||
|
||||
## By the numbers
|
||||
|
||||
{% table() %}
|
||||
| | |
|
||||
|---|---|
|
||||
| Games | 3 |
|
||||
| Flash | 14,616 bytes |
|
||||
| Static memory | 8 bytes |
|
||||
| Lines of game rules, `lib/games` | 719 |
|
||||
| Tests that play them | 44 |
|
||||
| The tune | 58 notes, 232 bytes |
|
||||
| Its pace | 150 to 272 beats a minute |
|
||||
| Pictures a second while a game runs | 30 |
|
||||
| Design questions | 17 |
|
||||
| Answered "Ok" | 17 |
|
||||
| Answers overturned by playing | 3 |
|
||||
| Games of Snake lost by a script, in the two runs counted | 35 |
|
||||
| Host tests | 673 |
|
||||
{% end %}
|
||||
|
||||
## Where it stands
|
||||
|
||||
{% steps() %}
|
||||
1. ~~M0 and M1: the skeleton, Wi-Fi, IRC, Wi-Fi Tools.~~ v0.1.0 to v0.2.1, [the first post](/devlog/roro9stack/).
|
||||
|
||||
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
|
||||
|
||||
3. ~~M2: GNSS.~~ v0.4.0, [Seventeen satellites](/devlog/roro9stack-gnss/).
|
||||
|
||||
4. ~~G1: Gemini.~~ v0.5.0, [A browser in the RAM IRC left over](/devlog/roro9stack-gemini/).
|
||||
|
||||
5. ~~M3: the LoRa radio, listening.~~ v0.6.0, [The loudest thing it hears is itself](/devlog/roro9stack-lora/).
|
||||
|
||||
6. ~~S1: the card, fixed addresses, the System App.~~ v0.6.1 to v0.8.1, [One byte too early](/devlog/roro9stack-s1/).
|
||||
|
||||
7. ~~F1 and the start of R1: files, notes, signed releases, updates from Gitea.~~ v0.9.0 to v0.11.0, [836 bytes](/devlog/roro9stack-f1-r1/).
|
||||
|
||||
8. ~~W1: the website, and one firmware with the Debug Console in it.~~ v0.12.0, [It was off](/devlog/roro9stack-console/).
|
||||
|
||||
9. ~~A help key, the Shell, notes of any size.~~ v0.13.0 to v0.15.0, [It said "No"](/devlog/roro9stack-shell/).
|
||||
|
||||
10. ~~Pictures, a screenshot key, the card in a phone's browser.~~ v0.16.0 to v0.18.0, [Press w](/devlog/roro9stack-share/).
|
||||
|
||||
11. ~~A WireGuard tunnel, and nine commands for a bad network day.~~ v0.19.0 to v0.21.0, [Six releases behind](/devlog/roro9stack-vpn/).
|
||||
|
||||
12. ~~SSH, MeshCore, icons and themes.~~ v0.22.0 to v0.23.0.
|
||||
|
||||
13. ~~A compass, updates that install themselves, and Pong, Snake and Tetris.~~ v0.24.0, this post.
|
||||
|
||||
14. Next: M4, the mesh, which still wants a second node.
|
||||
{% end %}
|
||||
|
||||
{% signoff() %}
|
||||
Fourteen thousand bytes, three games, one tune. Most of what this firmware does took measuring, and bit me first. This took an evening and asked for almost nothing. The device could already do a great deal. Now it is also something to pick up when there is nothing to do, and that was the missing feature.
|
||||
{% end %}
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,56 @@
|
||||
+++
|
||||
title = "Games"
|
||||
description = "Pong, Snake and Tetris, for when the radio is quiet."
|
||||
weight = 17
|
||||
[extra]
|
||||
tag = "Games"
|
||||
+++
|
||||
|
||||
The Games App opens on its three games, an icon each, as the Launcher shows Apps: <kbd>,</kbd> and <kbd>/</kbd> go from one to the next, with its name and your best so far under the row. <kbd>Enter</kbd> picks one, <kbd>Enter</kbd> again starts it.
|
||||
|
||||
In every game:
|
||||
|
||||
| Key | Does |
|
||||
|---|---|
|
||||
| <kbd>Enter</kbd> | Starts, or starts again when a game is over |
|
||||
| <kbd>p</kbd> | Pauses. Any key carries on |
|
||||
| <kbd>`</kbd> | Back to the three games. This one waits, paused |
|
||||
|
||||
A game you leave in the middle, for another game or another App, is **found again paused**, and its icon has an orange dot. While a game is being played the screen does not dim. The games beep, and Tetris has its tune, unless **Settings → This device → Sound & LED** is off. There is no choice of difficulty: each game starts gently and gets faster.
|
||||
|
||||
## Pong
|
||||
|
||||
You have the left paddle, the device the right one. Hold <kbd>;</kbd> to go up and <kbd>.</kbd> to go down. **First to 7 wins.**
|
||||
|
||||
Where the ball meets your paddle decides how it leaves: off the middle it goes straight, off an end it goes steeply. Each return makes the ball a little faster. The device's paddle only moves so fast: a steep, fast ball gets past it.
|
||||
|
||||
What is kept is your **longest rally**: the most returns you made between two serves.
|
||||
|
||||
## Snake
|
||||
|
||||
The arrows (<kbd>;</kbd> <kbd>.</kbd> <kbd>,</kbd> <kbd>/</kbd>) turn the snake. It grows by one with each thing it eats, and goes a little faster every third. **The walls kill it**, and so does its own body.
|
||||
|
||||
Two turns pressed quickly one after the other are both taken, one a step: that is how to turn round in two rows.
|
||||
|
||||
## Tetris
|
||||
|
||||
| Key | Does |
|
||||
|---|---|
|
||||
| <kbd>,</kbd> <kbd>/</kbd> | Left and right. Held, it repeats |
|
||||
| <kbd>;</kbd> | Turns the piece. <kbd>z</kbd> turns it the other way |
|
||||
| <kbd>.</kbd> | Falls faster, while held |
|
||||
| <kbd>1</kbd> | Drops it at once. It is far from the arrows on purpose: a drop cannot be taken back |
|
||||
| <kbd>0</kbd> | The music, on or off. The device remembers it |
|
||||
| <kbd>8</kbd> <kbd>9</kbd> | The music, quieter and louder, in eight steps. Remembered too |
|
||||
|
||||
The well is 10 wide and 20 high. The pieces come from a bag: each of the seven once, in a shuffled order, then seven again, so the long one is never more than twelve pieces away. The **next piece** is shown on the right. A piece turned against a wall shifts a step away from it.
|
||||
|
||||
It plays to **Korobeiniki**, the tune Tetris has always had, round and round, and **faster with each level**, as the fall is; it stops when the game is paused or over. With Sound & LED off in the Settings there is no music either.
|
||||
|
||||
A full row goes: 100 points for one, 300 for two, 500 for three, **800 for four at once**, times the level. Falling faster gives a point a row, dropping two. The key that brought a piece down does not send the next one down too: let it go and press again. Every ten rows is a level, and the fall quickens.
|
||||
|
||||
## The keys, as the device lists them
|
||||
|
||||
What <kbd>Fn</kbd> + <kbd>h</kbd> shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones.
|
||||
|
||||
{{ keys(scopes=["games", "games-pong", "games-snake", "games-tetris"]) }}
|
||||
@@ -50,6 +50,7 @@ Inside a group, move with the arrows. On a toggle, a choice or a slider, left an
|
||||
| Setting | What it is |
|
||||
|---|---|
|
||||
| **Check for updates** | Once a day, see [Updates](/guide/updates/) |
|
||||
| **Install updates** | `When asked` (the default), or `By itself`: a newer release is installed in a quiet moment, after a question that answers itself in 30 seconds. See [Updates](/guide/updates/) |
|
||||
| **Firmware** | The page described in [Updates](/guide/updates/) |
|
||||
| **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you |
|
||||
| **About** | The firmware version, the node number, battery, memory, uptime, clock and licence |
|
||||
|
||||
@@ -19,9 +19,23 @@ The page shows the **version** running, its **status**, the address to **push up
|
||||
|
||||
An install needs Wi-Fi if it is a download. The new firmware is checked before anything is written (the signature after the first 160 bytes) and again at the end (the image's hash). Then the device restarts. It will **wait up to 60 seconds** if you are typing, so that a restart never eats a note.
|
||||
|
||||
A download that **breaks** (the Wi-Fi drops, the link is poor) is not started over: the device waits up to a minute for Wi-Fi to come back, asks the server for the rest of the file, and carries on from where it was, as often as it takes while each try brings something more. It gives up after three tries in a row that bring nothing, and then nothing is changed. If the device is switched off in between, the download starts from the beginning next time.
|
||||
|
||||
## Check for updates
|
||||
|
||||
**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself, and it does not announce a version that already failed and rolled back on this device.
|
||||
**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself unless you ask for that (below), and it does not announce a version that already failed and rolled back on this device.
|
||||
|
||||
## Install updates by itself
|
||||
|
||||
**Settings → System → Install updates** is `When asked` by default. Set to `By itself`, a newer release found by the daily check is installed without your going to the Firmware page:
|
||||
|
||||
1. The device waits for a **quiet moment**: no key pressed for 2 minutes, and nothing a restart would cut short (a Track or a Capture recording, files being copied or received, an SSH session, a web share).
|
||||
2. It then asks on the screen, over whatever is open, and lights the screen if it was off: `Update to v0.24.0?`, with a count from **30 seconds**.
|
||||
3. **Cancel** (or <kbd>`</kbd>) leaves things as they are until the next day's check. **Accept**, or no answer when the count ends, downloads the release, installs it and restarts.
|
||||
|
||||
Everything else is as for an install you ask for: the signature is checked before anything is written, the new firmware runs on Probation, and a version that rolled back on this device is not offered again. It needs **Check for updates** to be on.
|
||||
|
||||
Each update that held, whoever started it, and each one that was undone, is written with its date in `/system/updates.log` on the SD card.
|
||||
|
||||
## Probation and Rollback
|
||||
|
||||
|
||||
@@ -385,6 +385,50 @@ rows = [
|
||||
["; .", "up, down"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "games"
|
||||
title = "Games, the three games"
|
||||
rows = [
|
||||
["Enter", "this game"],
|
||||
[", /", "the game before, the next"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "games-pong"
|
||||
title = "Games, Pong"
|
||||
rows = [
|
||||
["; .", "the paddle, while held"],
|
||||
["Enter", "start, or start again"],
|
||||
["p", "pause; any key carries on"],
|
||||
["`", "the other games; this one waits"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "games-snake"
|
||||
title = "Games, Snake"
|
||||
rows = [
|
||||
["; . , /", "turn"],
|
||||
["Enter", "start, or start again"],
|
||||
["p", "pause; any key carries on"],
|
||||
["`", "the other games; this one waits"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "games-tetris"
|
||||
title = "Games, Tetris"
|
||||
rows = [
|
||||
[", /", "left, right; held, it repeats"],
|
||||
[";", "turn"],
|
||||
["z", "turn the other way"],
|
||||
[".", "fall faster, while held"],
|
||||
["1", "drop"],
|
||||
["0", "the music, on or off"],
|
||||
["8 9", "the music, quieter, louder"],
|
||||
["Enter", "start, or start again"],
|
||||
["p", "pause; any key carries on"],
|
||||
["`", "the other games; this one waits"],
|
||||
]
|
||||
|
||||
[[scope]]
|
||||
id = "ssh"
|
||||
title = "SSH, the hosts"
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
#include "apps/games_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <M5Cardputer.h>
|
||||
#include <esp_random.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "app_keys.h"
|
||||
#include "ui/fonts.h"
|
||||
#include "ui/icons.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
using namespace games;
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kFrameMs = 33; // 30 pictures a second while a Game runs
|
||||
constexpr uint32_t kTapMs = 70; // how long a tap moves Pong's paddle
|
||||
constexpr int kCell = 6; // Snake's and Tetris's cells, in pixels
|
||||
const char* const kNames[3] = {"Pong", "Snake", "Tetris"};
|
||||
const char* const kBestKeys[3] = {"best_pong", "best_snake", "best_tetris"};
|
||||
|
||||
// The seven pieces in the theme's seven accents: they follow the theme chosen.
|
||||
uint16_t pieceColor(uint8_t kind) {
|
||||
switch (kind) {
|
||||
case 1: return theme::kAccent;
|
||||
case 2: return theme::kYellow;
|
||||
case 3: return theme::kViolet;
|
||||
case 4: return theme::kGood;
|
||||
case 5: return theme::kRed;
|
||||
case 6: return theme::kMessage;
|
||||
default: return theme::kWarning;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// The speaker mixes its channels: the tune has one, the beeps another, so a line cleared does
|
||||
// not cut a note short.
|
||||
constexpr int kTuneChannel = 0, kBeepChannel = 1;
|
||||
constexpr uint32_t kBreathMs = 25; // of silence at the end of each note, so two alike are two
|
||||
|
||||
void GamesApp::beep(int hz, int ms) {
|
||||
if (settings_.getBool(Setting::Sound)) M5Cardputer.Speaker.tone(hz, ms, kBeepChannel, true);
|
||||
}
|
||||
|
||||
bool GamesApp::musicOn() {
|
||||
if (music_ < 0) {
|
||||
int32_t stored = 1;
|
||||
music_ = store_.getInt("tetris_music", stored) ? (stored != 0) : 1; // on, until switched off
|
||||
}
|
||||
return music_ == 1;
|
||||
}
|
||||
|
||||
// The tune's loudness, in eight steps. The speaker is set for short beeps, at a quarter of what
|
||||
// it can do: for the tune it is turned up all the way, and each channel's own volume does the
|
||||
// rest, the tune's by the step chosen, the others' a quarter so a beep stays as loud as it was.
|
||||
constexpr int kLoudest = 8, kUsualVolume = 4;
|
||||
// The top is four fifths of all the speaker can do: above that the small speaker distorts.
|
||||
constexpr uint8_t kStepVolume[kLoudest] = {5, 10, 16, 26, 45, 77, 128, 204}; // 64 is the speaker's usual
|
||||
|
||||
int GamesApp::volume() {
|
||||
if (volume_ < 0) {
|
||||
int32_t stored = kUsualVolume;
|
||||
volume_ = store_.getInt("tetris_volume", stored) && stored >= 1 && stored <= kLoudest ? stored : kUsualVolume;
|
||||
}
|
||||
return volume_;
|
||||
}
|
||||
|
||||
void GamesApp::setVolume(int level) {
|
||||
level = std::clamp(level, 1, kLoudest);
|
||||
if (level == volume()) return;
|
||||
volume_ = level;
|
||||
store_.putInt("tetris_volume", level);
|
||||
if (masterWas_ >= 0) M5Cardputer.Speaker.setChannelVolume(kTuneChannel, kStepVolume[level - 1]);
|
||||
}
|
||||
|
||||
void GamesApp::loud(bool on) {
|
||||
auto& speaker = M5Cardputer.Speaker;
|
||||
if (on == (masterWas_ >= 0)) return;
|
||||
if (on) {
|
||||
masterWas_ = speaker.getVolume();
|
||||
speaker.setAllChannelVolume(static_cast<uint8_t>(masterWas_)); // times a master of 255: as before
|
||||
speaker.setChannelVolume(kTuneChannel, kStepVolume[volume() - 1]);
|
||||
speaker.setVolume(255);
|
||||
} else {
|
||||
speaker.setVolume(static_cast<uint8_t>(masterWas_));
|
||||
speaker.setAllChannelVolume(255);
|
||||
masterWas_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void GamesApp::quiet() {
|
||||
M5Cardputer.Speaker.stop(kTuneChannel);
|
||||
loud(false);
|
||||
}
|
||||
|
||||
int GamesApp::best(Which w) {
|
||||
if (best_[w] < 0) {
|
||||
int32_t stored = 0;
|
||||
best_[w] = store_.getInt(kBestKeys[w], stored) && stored > 0 ? stored : 0;
|
||||
}
|
||||
return best_[w];
|
||||
}
|
||||
|
||||
void GamesApp::onEnter() {
|
||||
lastMs_ = millis();
|
||||
requestRedraw();
|
||||
}
|
||||
|
||||
void GamesApp::pause() {
|
||||
if (which_ != Menu && state_[which_] == State::Playing) state_[which_] = State::Paused;
|
||||
quiet();
|
||||
}
|
||||
|
||||
void GamesApp::onExit() { pause(); }
|
||||
|
||||
void GamesApp::begin() {
|
||||
uint32_t seed = esp_random();
|
||||
if (which_ == Pong) pong_.reset(new games::Pong(seed));
|
||||
else if (which_ == Snake) snake_.reset(new games::Snake(seed));
|
||||
else if (which_ == Tetris) tetris_.reset(new games::Tetris(seed));
|
||||
state_[which_] = State::Playing;
|
||||
tune_.restart();
|
||||
newBest_ = false;
|
||||
tapped_ = TetrisInput();
|
||||
tapMove_ = 0;
|
||||
lastMs_ = millis();
|
||||
}
|
||||
|
||||
void GamesApp::ended(int score) {
|
||||
state_[which_] = State::Over;
|
||||
quiet();
|
||||
newBest_ = score > best(which_);
|
||||
if (newBest_) {
|
||||
best_[which_] = score;
|
||||
store_.putInt(kBestKeys[which_], score);
|
||||
}
|
||||
beep(newBest_ ? 1320 : 220, 250);
|
||||
}
|
||||
|
||||
bool GamesApp::onKey(const KeyEvent& e) {
|
||||
requestRedraw();
|
||||
if (which_ == Menu) {
|
||||
switch (e.key) {
|
||||
case Key::Left: chosen_ = (chosen_ + 2) % 3; return true;
|
||||
case Key::Right: chosen_ = (chosen_ + 1) % 3; return true;
|
||||
case Key::Select:
|
||||
which_ = static_cast<Which>(chosen_);
|
||||
lastMs_ = millis();
|
||||
return true;
|
||||
default: return false; // Back leaves the App
|
||||
}
|
||||
}
|
||||
State& state = state_[which_];
|
||||
if (which_ == Tetris && e.key == Key::Char && e.ch == '0') { // the tune, on or off, whatever the game is doing
|
||||
music_ = musicOn() ? 0 : 1;
|
||||
store_.putInt("tetris_music", music_);
|
||||
if (!music_) quiet();
|
||||
return true;
|
||||
}
|
||||
if (which_ == Tetris && e.key == Key::Char && (e.ch == '8' || e.ch == '9')) { // quieter, louder
|
||||
setVolume(volume() + (e.ch == '9' ? 1 : -1));
|
||||
return true;
|
||||
}
|
||||
if (e.key == Key::Back) { // to the list; the Game waits, paused
|
||||
pause();
|
||||
which_ = Menu;
|
||||
return true;
|
||||
}
|
||||
if (state == State::Ready || state == State::Over) {
|
||||
if (e.key == Key::Select) begin();
|
||||
return true;
|
||||
}
|
||||
if (state == State::Paused) { // any key carries on
|
||||
state = State::Playing;
|
||||
lastMs_ = millis();
|
||||
return true;
|
||||
}
|
||||
if (e.key == Key::Char && (e.ch == 'p' || e.ch == 'P')) {
|
||||
pause();
|
||||
return true;
|
||||
}
|
||||
return which_ == Pong ? pongKey(e) : which_ == Snake ? snakeKey(e) : tetrisKey(e);
|
||||
}
|
||||
|
||||
bool GamesApp::pongKey(const KeyEvent& e) {
|
||||
if (e.key != Key::Up && e.key != Key::Down) return true;
|
||||
tapMove_ = e.key == Key::Up ? -1 : 1;
|
||||
tapUntilMs_ = millis() + kTapMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GamesApp::snakeKey(const KeyEvent& e) {
|
||||
switch (e.key) {
|
||||
case Key::Up: snake_->turn(Snake::Dir::Up); break;
|
||||
case Key::Down: snake_->turn(Snake::Dir::Down); break;
|
||||
case Key::Left: snake_->turn(Snake::Dir::Left); break;
|
||||
case Key::Right: snake_->turn(Snake::Dir::Right); break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool GamesApp::tetrisKey(const KeyEvent& e) {
|
||||
switch (e.key) {
|
||||
case Key::Up: tapped_.rotate = true; break;
|
||||
case Key::Left: tapped_.left = true; break;
|
||||
case Key::Right: tapped_.right = true; break;
|
||||
case Key::Down: tapped_.soft = true; break;
|
||||
case Key::Char:
|
||||
if (e.ch == 'z' || e.ch == 'Z') tapped_.rotateBack = true;
|
||||
else if (e.ch == '1') tapped_.hardDrop = true; // far from the arrows: not hit by mistake
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void GamesApp::update(uint32_t nowMs) {
|
||||
uint32_t ms = std::min<uint32_t>(nowMs - lastMs_, 100);
|
||||
if (which_ == Menu || state_[which_] != State::Playing) {
|
||||
lastMs_ = nowMs;
|
||||
return;
|
||||
}
|
||||
if (ms == 0) return;
|
||||
lastMs_ = nowMs;
|
||||
|
||||
if (which_ == Pong) {
|
||||
int move = keys_.held(Key::Up) ? -1 : keys_.held(Key::Down) ? 1 : 0;
|
||||
if (!move && static_cast<int32_t>(tapUntilMs_ - nowMs) > 0) move = tapMove_;
|
||||
uint8_t events = pong_->step(ms / 1000.0f, move);
|
||||
if (events & Pong::Over) ended(pong_->longestRally());
|
||||
else if (events & Pong::Point) beep(330, 120);
|
||||
else if (events & Pong::Hit) beep(880, 15);
|
||||
else if (events & Pong::Wall) beep(440, 10);
|
||||
} else if (which_ == Snake) {
|
||||
uint8_t events = snake_->step(ms);
|
||||
if (events & Snake::Died) ended(snake_->score());
|
||||
else if (events & Snake::Ate) beep(990, 25);
|
||||
} else {
|
||||
TetrisInput in = tapped_;
|
||||
tapped_ = TetrisInput();
|
||||
in.left |= keys_.held(Key::Left);
|
||||
in.right |= keys_.held(Key::Right);
|
||||
in.soft |= keys_.held(Key::Down);
|
||||
uint8_t events = tetris_->step(ms, in);
|
||||
if (events & Tetris::Over) ended(tetris_->score());
|
||||
else if (events & Tetris::LevelUp) beep(1320, 120);
|
||||
else if (events & Tetris::Lines) beep(tetris_->lastCleared() == 4 ? 1320 : 880, 60);
|
||||
else if (events & Tetris::Locked) beep(220, 12);
|
||||
Note note{};
|
||||
uint32_t length = 0;
|
||||
tune_.setEighthMs(korobeiniki::eighthMsAt(tetris_->level())); // it hurries as the game does
|
||||
if (state_[Tetris] == State::Playing && musicOn() && settings_.getBool(Setting::Sound) && tune_.step(ms, note, length) && note.hz) {
|
||||
loud(true);
|
||||
M5Cardputer.Speaker.tone(note.hz, length - kBreathMs, kTuneChannel, true);
|
||||
}
|
||||
}
|
||||
if (nowMs - lastDrawMs_ >= kFrameMs) {
|
||||
lastDrawMs_ = nowMs;
|
||||
requestRedraw();
|
||||
}
|
||||
}
|
||||
|
||||
void GamesApp::help(std::vector<KeyHelp>& out) const {
|
||||
switch (which_) {
|
||||
case Pong: return keys::add(out, keys::kGamesPong);
|
||||
case Snake: return keys::add(out, keys::kGamesSnake);
|
||||
case Tetris: return keys::add(out, keys::kGamesTetris);
|
||||
default: return keys::add(out, keys::kGames);
|
||||
}
|
||||
}
|
||||
|
||||
const char* GamesApp::helpTitle() const {
|
||||
return which_ == Pong ? "Games: Pong" : which_ == Snake ? "Games: Snake" : which_ == Tetris ? "Games: Tetris" : nullptr;
|
||||
}
|
||||
|
||||
// A box in the middle of the field: where the Game stands when it is not running.
|
||||
void GamesApp::drawBanner(Canvas& c, const std::string& title, const std::string& detail) {
|
||||
const auto& area = theme::kContent;
|
||||
const int w = 150, h = detail.empty() ? 24 : 40, x = (area.w - w) / 2, y = area.y + (area.h - h) / 2;
|
||||
c.fillRect(x, y, w, h, theme::kBackground);
|
||||
c.drawRect(x, y, w, h, theme::kAccent);
|
||||
c.setTextDatum(top_center);
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString(title.c_str(), area.w / 2, y + 5);
|
||||
if (!detail.empty()) {
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(detail.c_str(), area.w / 2, y + 21);
|
||||
}
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
|
||||
// The three Games as the Launcher shows Apps: an icon each, the chosen one on a cyan tile, its
|
||||
// name under the row. A Game waiting paused has the Launcher's orange dot.
|
||||
void GamesApp::drawMenu(Canvas& c) {
|
||||
using namespace theme;
|
||||
static const uint8_t* const kIcons[3] = {icons::pong, icons::snake, icons::tetris};
|
||||
const int tileW = 60, tileH = 36, x0 = (kContent.w - 3 * tileW) / 2, y = kContent.y + 24;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int x = x0 + i * tileW, ix = x + (tileW - icons::kSize) / 2, iy = y + (tileH - icons::kSize) / 2;
|
||||
bool chosen = i == chosen_;
|
||||
if (chosen) {
|
||||
int tx = ix - 6, tw = icons::kSize + 12;
|
||||
c.fillRect(tx + 4, y, tw - 8, tileH, kAccent);
|
||||
c.fillRect(tx + 2, y + 2, tw - 4, tileH - 4, kAccent);
|
||||
c.fillRect(tx, y + 4, tw, tileH - 8, kAccent);
|
||||
}
|
||||
c.drawBitmap(ix, iy, kIcons[i], icons::kSize, icons::kSize, chosen ? kFaint : kAccent);
|
||||
if (state_[i] == State::Paused) {
|
||||
c.fillRect(ix + icons::kSize - 6, iy - 1, 10, 10, chosen ? kAccent : kBackground);
|
||||
c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning);
|
||||
}
|
||||
}
|
||||
auto w = static_cast<Which>(chosen_);
|
||||
c.setTextDatum(top_center);
|
||||
c.setFont(&fonts::bold);
|
||||
c.setTextColor(kText);
|
||||
c.drawString(kNames[chosen_], kContent.w / 2, y + tileH + 8);
|
||||
int b = best(w);
|
||||
std::string under = state_[chosen_] == State::Paused ? "paused" : "";
|
||||
if (b > 0) under += (under.empty() ? "" : ", ") + std::string(w == Pong ? "longest rally " : "best ") + std::to_string(b);
|
||||
c.setFont(&fonts::body);
|
||||
c.setTextColor(kMuted);
|
||||
c.drawString(under.c_str(), kContent.w / 2, y + tileH + 8 + kLineHeight + 2);
|
||||
c.setTextDatum(top_left);
|
||||
}
|
||||
|
||||
void GamesApp::drawPong(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
State state = state_[Pong];
|
||||
const int top = area.y;
|
||||
for (int y = top + 2; y < top + static_cast<int>(Pong::kHeight); y += 8) c.drawFastVLine(area.w / 2, y, 4, theme::kFaint);
|
||||
if (pong_) {
|
||||
char score[8];
|
||||
c.setFont(&fonts::large);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.setTextDatum(top_center);
|
||||
std::snprintf(score, sizeof score, "%d", pong_->playerScore());
|
||||
c.drawString(score, area.w / 2 - 24, top + 3);
|
||||
std::snprintf(score, sizeof score, "%d", pong_->deviceScore());
|
||||
c.drawString(score, area.w / 2 + 24, top + 3);
|
||||
c.setTextDatum(top_left);
|
||||
const int ph = static_cast<int>(Pong::kPaddleH), pw = static_cast<int>(Pong::kPaddleW), m = static_cast<int>(Pong::kMargin);
|
||||
c.fillRect(m, top + static_cast<int>(pong_->playerY()) - ph / 2, pw, ph, theme::kAccent);
|
||||
c.fillRect(area.w - m - pw, top + static_cast<int>(pong_->deviceY()) - ph / 2, pw, ph, theme::kWarning);
|
||||
const int b = static_cast<int>(Pong::kBall);
|
||||
c.fillRect(static_cast<int>(pong_->ballX()) - b / 2, top + static_cast<int>(pong_->ballY()) - b / 2, b, b, theme::kText);
|
||||
}
|
||||
int b = best(Pong);
|
||||
std::string bestLine = b > 0 ? "longest rally " + std::to_string(b) : "first to 7";
|
||||
if (state == State::Ready) drawBanner(c, "Pong", bestLine);
|
||||
else if (state == State::Paused) drawBanner(c, "Paused", "");
|
||||
else if (state == State::Over)
|
||||
drawBanner(c, pong_->playerWon() ? "You win" : "The device wins",
|
||||
newBest_ ? "a new longest rally: " + std::to_string(b) : "rally " + std::to_string(pong_->longestRally()) + ", best " + std::to_string(b));
|
||||
}
|
||||
|
||||
void GamesApp::drawSnake(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
State state = state_[Snake];
|
||||
const int w = Snake::kCols * kCell, h = Snake::kRows * kCell, x0 = (area.w - w) / 2, y0 = area.y + area.h - h - 1;
|
||||
c.drawRect(x0 - 1, y0 - 1, w + 2, h + 2, theme::kFaint);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
char line[32];
|
||||
std::snprintf(line, sizeof line, "%d", snake_ ? snake_->score() : 0);
|
||||
c.drawString(line, x0, area.y);
|
||||
std::snprintf(line, sizeof line, "best %d", best(Snake));
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(line, x0 + w, area.y);
|
||||
c.setTextDatum(top_left);
|
||||
if (snake_) {
|
||||
auto f = snake_->food();
|
||||
c.fillRect(x0 + f.col * kCell + 1, y0 + f.row * kCell + 1, kCell - 2, kCell - 2, theme::kWarning);
|
||||
bool head = true;
|
||||
for (auto& cell : snake_->body()) {
|
||||
c.fillRect(x0 + cell.col * kCell, y0 + cell.row * kCell, kCell - 1, kCell - 1, head ? theme::kText : theme::kGood);
|
||||
head = false;
|
||||
}
|
||||
}
|
||||
if (state == State::Ready) drawBanner(c, "Snake", "the walls bite");
|
||||
else if (state == State::Paused) drawBanner(c, "Paused", "");
|
||||
else if (state == State::Over)
|
||||
drawBanner(c, "Game over", newBest_ ? "a new best: " + std::to_string(snake_->score()) : std::to_string(snake_->score()) + ", best " + std::to_string(best(Snake)));
|
||||
}
|
||||
|
||||
void GamesApp::drawTetris(Canvas& c) {
|
||||
const auto& area = theme::kContent;
|
||||
State state = state_[Tetris];
|
||||
const int w = Tetris::kCols * kCell, h = Tetris::kRows * kCell, x0 = (area.w - w) / 2, y0 = area.y + 1;
|
||||
c.drawFastVLine(x0 - 1, y0, h + 1, theme::kMuted);
|
||||
c.drawFastVLine(x0 + w, y0, h + 1, theme::kMuted);
|
||||
c.drawFastHLine(x0 - 1, y0 + h, w + 2, theme::kMuted);
|
||||
auto block = [&](int x, int y, uint8_t kind) { c.fillRect(x, y, kCell - 1, kCell - 1, pieceColor(kind)); };
|
||||
|
||||
c.setFont(&fonts::body);
|
||||
const int right = x0 + w + 12, left = 6;
|
||||
auto readout = [&](int x, int y, const char* label, int value) {
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString(label, x, y);
|
||||
c.setTextColor(theme::kText);
|
||||
c.drawString(std::to_string(value).c_str(), x, y + theme::kLineHeight);
|
||||
};
|
||||
readout(left, area.y + 4, "Score", tetris_ ? tetris_->score() : 0);
|
||||
readout(left, area.y + 38, "Best", best(Tetris));
|
||||
readout(left, area.y + 72, "Lines", tetris_ ? tetris_->lines() : 0);
|
||||
c.setTextColor(theme::kMuted);
|
||||
c.drawString("Next", right, area.y + 4);
|
||||
readout(right, area.y + 72, "Level", tetris_ ? tetris_->level() + 1 : 1);
|
||||
c.setFont(&fonts::small);
|
||||
c.setTextColor(theme::kMuted);
|
||||
char music[20];
|
||||
std::snprintf(music, sizeof music, "music %d/%d", volume(), kLoudest);
|
||||
c.drawString(!settings_.getBool(Setting::Sound) ? "sound off" : musicOn() ? music : "music off", right, area.y + area.h - 10);
|
||||
c.setFont(&fonts::body);
|
||||
|
||||
if (tetris_) {
|
||||
for (int row = 0; row < Tetris::kRows; row++)
|
||||
for (int col = 0; col < Tetris::kCols; col++)
|
||||
if (uint8_t kind = tetris_->cell(col, row)) block(x0 + col * kCell, y0 + row * kCell, kind);
|
||||
if (state != State::Over)
|
||||
for (auto& cell : Tetris::cells(tetris_->piece()))
|
||||
if (cell.row >= 0) block(x0 + cell.col * kCell, y0 + cell.row * kCell, tetris_->piece().kind);
|
||||
Tetris::Piece next{tetris_->next(), 0, 0, 0};
|
||||
for (auto& cell : Tetris::cells(next)) block(right + cell.col * kCell, area.y + 22 + cell.row * kCell, next.kind);
|
||||
}
|
||||
if (state == State::Ready) drawBanner(c, "Tetris", "");
|
||||
else if (state == State::Paused) drawBanner(c, "Paused", "");
|
||||
else if (state == State::Over)
|
||||
drawBanner(c, "Game over", newBest_ ? "a new best: " + std::to_string(tetris_->score()) : std::to_string(tetris_->lines()) + " lines");
|
||||
}
|
||||
|
||||
void GamesApp::draw(Canvas& c) {
|
||||
c.setTextDatum(top_left);
|
||||
lastDrawMs_ = millis();
|
||||
switch (which_) {
|
||||
case Pong: drawPong(c); break;
|
||||
case Snake: drawSnake(c); break;
|
||||
case Tetris: drawTetris(c); break;
|
||||
default: drawMenu(c); break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "app.h"
|
||||
#include "key_mapper.h"
|
||||
#include "key_value_store.h"
|
||||
#include "pong.h"
|
||||
#include "settings.h"
|
||||
#include "snake.h"
|
||||
#include "tetris.h"
|
||||
#include "tune.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// The Games App (issue #101): three Games as a row of icons, and the one being played. The rules are in
|
||||
// lib/games; this draws them, gives them the keys and the time, beeps, and keeps the best of each.
|
||||
// A Game left in the middle, for the list or for another App, is found again paused.
|
||||
class GamesApp : public App {
|
||||
public:
|
||||
GamesApp(const KeyMapper& keys, const Settings& settings, KeyValueStore& store) : keys_(keys), settings_(settings), store_(store) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
void help(std::vector<KeyHelp>& out) const override;
|
||||
const char* helpTitle() const override;
|
||||
// A Game being played is the device in use, key or no key: the screen stays lit.
|
||||
bool keepsScreenOn() const override { return which_ != Menu && state_[which_] == State::Playing; }
|
||||
|
||||
private:
|
||||
enum Which { Pong, Snake, Tetris, Menu }; // the first three index the arrays
|
||||
enum class State : uint8_t { Ready, Playing, Paused, Over };
|
||||
|
||||
void begin(); // a new game of the one chosen
|
||||
void pause();
|
||||
void ended(int score);
|
||||
void beep(int hz, int ms);
|
||||
bool musicOn();
|
||||
int volume(); // of the tune, 1 to kLoudest
|
||||
void setVolume(int level);
|
||||
void quiet(); // the tune stops where it is
|
||||
void loud(bool on); // the speaker turned up for the tune, or back as it was
|
||||
int best(Which w);
|
||||
bool pongKey(const KeyEvent& e);
|
||||
bool snakeKey(const KeyEvent& e);
|
||||
bool tetrisKey(const KeyEvent& e);
|
||||
void drawMenu(Canvas& c);
|
||||
void drawPong(Canvas& c);
|
||||
void drawSnake(Canvas& c);
|
||||
void drawTetris(Canvas& c);
|
||||
void drawBanner(Canvas& c, const std::string& title, const std::string& detail);
|
||||
|
||||
const KeyMapper& keys_;
|
||||
const Settings& settings_;
|
||||
KeyValueStore& store_;
|
||||
int chosen_ = 0; // in the row of three
|
||||
Which which_ = Menu;
|
||||
State state_[3] = {State::Ready, State::Ready, State::Ready};
|
||||
int best_[3] = {-1, -1, -1}; // -1: not read from the store yet
|
||||
bool newBest_ = false;
|
||||
std::unique_ptr<games::Pong> pong_;
|
||||
std::unique_ptr<games::Snake> snake_;
|
||||
std::unique_ptr<games::Tetris> tetris_;
|
||||
uint32_t lastMs_ = 0, lastDrawMs_ = 0;
|
||||
// Keys that came as presses since the last step: a tap moves too, held or not.
|
||||
int tapMove_ = 0;
|
||||
uint32_t tapUntilMs_ = 0;
|
||||
games::TetrisInput tapped_;
|
||||
// Tetris's tune (Korobeiniki), switched with 0 and remembered.
|
||||
games::TunePlayer tune_{games::korobeiniki::at, games::korobeiniki::kCount, games::korobeiniki::kEighthMs};
|
||||
int music_ = -1, volume_ = -1; // -1: not read from the store yet
|
||||
int masterWas_ = -1; // the speaker's own volume, while the tune has it turned up
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -18,8 +18,10 @@
|
||||
#include "services/ssh_service.h"
|
||||
#include "services/vpn_service.h"
|
||||
#include "services/web_share.h"
|
||||
#include "apps/games_app.h"
|
||||
#include "apps/gemini_app.h"
|
||||
#include "apps/gnss_app.h"
|
||||
#include "auto_update.h"
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/lora_scanner_app.h"
|
||||
@@ -81,6 +83,12 @@ static Screen screen;
|
||||
static BatteryService* battery;
|
||||
static StorageService* storageService;
|
||||
static FileOps* fileOps;
|
||||
static WebShare* webShare;
|
||||
// Installing a release by itself (issue #52): see autoUpdateStep().
|
||||
static release::AutoUpdate autoUpdate;
|
||||
static std::unique_ptr<DialogModel> autoDialog;
|
||||
static uint32_t lastKeyMs = 0;
|
||||
static bool autoRedraw = false;
|
||||
static std::vector<std::string> filesInUse(const std::string& path, bool folder);
|
||||
static PowerService* power;
|
||||
static ClockService* clockService;
|
||||
@@ -238,7 +246,7 @@ void setup() {
|
||||
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings, *storageService), icons::gnss});
|
||||
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService), icons::gemini});
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService), icons::lora});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi)), icons::storage});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *(webShare = new WebShare(*storageService, *fileOps, *wifi))), icons::storage});
|
||||
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power), icons::notes});
|
||||
sshService = new SshService(settings);
|
||||
sshApp = new SshApp(*sshService, settings, *storageService);
|
||||
@@ -254,6 +262,7 @@ void setup() {
|
||||
netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); };
|
||||
// A long note being rewritten (issue #47): the editor waits for the card, so it draws from there.
|
||||
NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); };
|
||||
apps->registerApp({"games", "Games", false, new GamesApp(keyMapper, settings, nvs), icons::games});
|
||||
apps->registerApp({"system", "System", false,
|
||||
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs), icons::system});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
@@ -569,6 +578,10 @@ static void updateCommand(const String& args) {
|
||||
console.printf("update: running %s, failed here before: %s, daily check %s, heap %u\n", update->runningVersion().c_str(),
|
||||
update->failedVersion().empty() ? "none" : update->failedVersion().c_str(),
|
||||
settings.getBool(Setting::CheckUpdates) ? "on" : "off", (unsigned)ESP.getFreeHeap());
|
||||
console.printf("update: installing by itself %s%s%s\n", settings.getBool(Setting::InstallUpdates) ? "on" : "off",
|
||||
autoUpdate.waiting() ? ", waiting for a quiet moment to offer " : autoUpdate.asking() ? ", asking about " : "",
|
||||
autoUpdate.waiting() || autoUpdate.asking() ? autoUpdate.tag().c_str() : "");
|
||||
console.printf("update: the last download was carried on %d time(s) after a break\n", update->lastResumes());
|
||||
console.printf("update: gitea %s %s\n", g.status() == GiteaReleases::Status::Done ? "done" : g.status() == GiteaReleases::Status::Failed ? "failed" : g.status() == GiteaReleases::Status::Busy ? "busy" : "never asked", g.error().c_str());
|
||||
printReleases(false);
|
||||
} else if (args.startsWith("install ")) {
|
||||
@@ -584,7 +597,8 @@ static void updateCommand(const String& args) {
|
||||
} else if (args.startsWith("damage ")) { // update damage cut <bytes> | flip <offset>: for the next download
|
||||
long n = args.substring(args.lastIndexOf(' ') + 1).toInt();
|
||||
args.startsWith("damage cut") ? update->damageNextDownload(n, -1) : update->damageNextDownload(-1, n);
|
||||
console.printf("update: the next download will be %s at byte %ld\n", args.startsWith("damage cut") ? "cut" : "damaged", n);
|
||||
if (args.startsWith("damage cut")) console.printf("update: each connection of the next download will break after %ld bytes\n", n);
|
||||
else console.printf("update: the next download will be damaged at byte %ld\n", n);
|
||||
|
||||
} else if (args == "daily") { // forget today's check: the daily one runs again at once, if it may
|
||||
update->forgetToday();
|
||||
@@ -849,8 +863,57 @@ static void screenshotKey() {
|
||||
}
|
||||
|
||||
// Every key goes through here, from the keyboard or from a console's `key`.
|
||||
// Installing a release by itself (issue #52, Settings > System > Install updates): once the daily
|
||||
// check has found one, wait until nothing would be cut by a restart and no key was pressed for a
|
||||
// while, then ask on the screen, over whatever is open. Left unanswered for 30 s, it is installed.
|
||||
|
||||
// Would a restart now cut something short?
|
||||
static bool busyForRestart() {
|
||||
return gnssService->tracking() || loraCapture->capturing() || upload.active() || fileOps->busy() ||
|
||||
(scpService && scpService->busy()) || (sshService && sshService->active()) || (webShare && webShare->running()) ||
|
||||
(noiseTest && noiseTest->running()) || update->phase() != UpdateService::Phase::Idle || update->onProbation() ||
|
||||
update->giteaBusy();
|
||||
}
|
||||
|
||||
static void autoUpdateStep(uint32_t now) {
|
||||
static int shownSeconds = -1;
|
||||
if (!settings.getBool(Setting::InstallUpdates) && (autoUpdate.waiting() || autoUpdate.asking())) autoUpdate.withdraw();
|
||||
std::string tag;
|
||||
if (update->takeOffer(tag)) autoUpdate.offer(tag);
|
||||
if (autoDialog && autoDialog->result() != DialogModel::kPending) autoUpdate.answer(autoDialog->result() == 1);
|
||||
switch (autoUpdate.step(now, busyForRestart(), now - lastKeyMs)) {
|
||||
case release::AutoUpdate::Action::None: break;
|
||||
case release::AutoUpdate::Action::Ask:
|
||||
autoDialog.reset(new DialogModel({"Cancel", "Accept"}));
|
||||
power->onNotification(now, now + release::AutoUpdate::kAskMs); // a screen that is off lights up for it
|
||||
autoRedraw = true;
|
||||
break;
|
||||
case release::AutoUpdate::Action::Dismiss:
|
||||
autoDialog.reset();
|
||||
autoRedraw = true;
|
||||
break;
|
||||
case release::AutoUpdate::Action::Install: {
|
||||
autoDialog.reset();
|
||||
autoRedraw = true;
|
||||
std::string why = update->requestInstall(autoUpdate.tag(), true);
|
||||
if (!why.empty())
|
||||
bus.publish(Event::withText(EventType::Notification, ("Update not installed: " + why).c_str(), static_cast<int32_t>(NotificationLevel::Warning)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
int seconds = autoUpdate.asking() ? autoUpdate.secondsLeft(now) : -1;
|
||||
if (seconds != shownSeconds) {
|
||||
shownSeconds = seconds;
|
||||
autoRedraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
static void handleKey(const KeyEvent& e) {
|
||||
if (e.key == Key::Screenshot) return screenshotKey();
|
||||
if (autoDialog) { // the question takes every key until it is answered
|
||||
autoDialog->onKey(e);
|
||||
return;
|
||||
}
|
||||
apps->handleKey(e);
|
||||
}
|
||||
|
||||
@@ -1534,6 +1597,7 @@ static void loopPass() {
|
||||
printListingWhenReady();
|
||||
M5Cardputer.update();
|
||||
if (M5Cardputer.Keyboard.isChange()) {
|
||||
lastKeyMs = now;
|
||||
bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen
|
||||
keyMapper.setTextEntry(apps->foreground().textEntryActive());
|
||||
auto events = keyMapper.update(readKeys()); // always, so held keys are tracked
|
||||
@@ -1549,6 +1613,15 @@ static void loopPass() {
|
||||
bus.dispatch();
|
||||
notifier->update(now);
|
||||
apps->update(now);
|
||||
// An App in use with no key pressed (a Game being played): the screen stays lit, and it is
|
||||
// not the quiet moment an update waits for.
|
||||
static uint32_t keptOnMs = 0;
|
||||
if (apps->foreground().keepsScreenOn() && now - keptOnMs >= 1000) {
|
||||
keptOnMs = now;
|
||||
lastKeyMs = now;
|
||||
power->onKey(now);
|
||||
}
|
||||
autoUpdateStep(now);
|
||||
|
||||
if (now - lastLog > 10000) {
|
||||
lastLog = now;
|
||||
@@ -1594,10 +1667,15 @@ static void loopPass() {
|
||||
screen.invalidate();
|
||||
redrawAfterUpdate = true;
|
||||
}
|
||||
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) {
|
||||
if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate || autoRedraw) {
|
||||
lastStatus = status;
|
||||
redrawAfterUpdate = false;
|
||||
screen.render(*apps, status, toast);
|
||||
autoRedraw = false;
|
||||
if (autoDialog) {
|
||||
Screen::Question question{"Update to " + autoUpdate.tag() + "?",
|
||||
"It installs in " + std::to_string(autoUpdate.secondsLeft(now)) + " s unless you cancel, and the device restarts.", autoDialog.get()};
|
||||
screen.render(*apps, status, toast, &question);
|
||||
} else screen.render(*apps, status, toast);
|
||||
update->firstFrameDrawn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,20 @@ std::string whyNotConnected(NetworkClientSecure& tls, const std::string& host) {
|
||||
} // namespace
|
||||
|
||||
std::string HttpsGet::open(const std::string& host, const std::string& path, const char* accept) {
|
||||
std::string why = request(host, path, accept, "", kNeedFree);
|
||||
if (!why.empty()) return why;
|
||||
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string HttpsGet::openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange) {
|
||||
return request(host, path, accept, "Range: bytes=" + std::to_string(from) + "-\r\n" + (ifRange.empty() ? "" : "If-Range: " + ifRange + "\r\n"), kNeedFreeToCarryOn);
|
||||
}
|
||||
|
||||
std::string HttpsGet::request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree) {
|
||||
close();
|
||||
if (time(nullptr) < kClockSetAfter) return "The clock isn't set: can't check certificates";
|
||||
if (esp_get_free_heap_size() < kNeedFree) return "Not enough memory for a secure connection";
|
||||
if (esp_get_free_heap_size() < needFree) return "Not enough memory for a secure connection";
|
||||
|
||||
tls_.setCACert(kTrustedRootsPem);
|
||||
tls_.setTimeout(15);
|
||||
@@ -38,7 +49,7 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
|
||||
raw_.reset(new (std::nothrow) uint8_t[kRaw]);
|
||||
if (!raw_) return "Not enough memory";
|
||||
tls_.print(("GET " + path + " HTTP/1.1\r\nHost: " + host + "\r\nUser-Agent: roro9stack/" + versionString() +
|
||||
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n")
|
||||
"\r\nAccept: " + accept + "\r\nAccept-Encoding: identity\r\n" + more + "Connection: close\r\n\r\n")
|
||||
.c_str());
|
||||
|
||||
release::HttpHeadParser parser;
|
||||
@@ -50,7 +61,6 @@ std::string HttpsGet::open(const std::string& host, const std::string& path, con
|
||||
if (parser.complete() && used < static_cast<size_t>(n)) early_.assign(reinterpret_cast<const char*>(raw_.get()) + used, n - used);
|
||||
}
|
||||
head_ = parser.head();
|
||||
if (head_.status != 200) return host + " answered " + std::to_string(head_.status) + (head_.status / 100 == 3 ? " (a redirect)" : "");
|
||||
left_ = head_.chunked ? -1 : head_.contentLength;
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -20,12 +20,22 @@ class HttpsGet {
|
||||
// checking the certificate); with Q86's 20 KB to spare, it starts with at least this much free.
|
||||
static constexpr size_t kNeedFree = 80 * 1024;
|
||||
|
||||
// Carrying on a download (issue #54) happens in the middle of an install, whose own buffers
|
||||
// (about 8 KB, measured: 78 KB free between two connections) are already taken out of what is
|
||||
// free, and with the screen taken over nothing else will ask for memory: the peak and 12 KB.
|
||||
static constexpr size_t kNeedFreeToCarryOn = 64 * 1024;
|
||||
|
||||
explicit HttpsGet(net::User user) : tls_(user) {}
|
||||
~HttpsGet() { close(); }
|
||||
|
||||
// Connects and reads the head. "" when a 200 is on its way, or why not, in words for the screen.
|
||||
std::string open(const std::string& host, const std::string& path, const char* accept);
|
||||
// The same, for the rest of a file from byte `from` (issue #54): asks for that range, with
|
||||
// `ifRange` (an ETag, or "") so that a file that changed comes whole. The answer, a 206 or
|
||||
// not, is for the caller to judge with head(): "" here only says a head was read.
|
||||
std::string openFrom(const std::string& host, const std::string& path, const char* accept, long from, const std::string& ifRange);
|
||||
long contentLength() const { return head_.contentLength; } // -1: not known
|
||||
const release::HttpHead& head() const { return head_; }
|
||||
|
||||
// Body bytes into `buf`: how many, 0 at the end, -1 when the connection broke or stalled
|
||||
// before the end.
|
||||
@@ -37,6 +47,7 @@ class HttpsGet {
|
||||
static constexpr uint32_t kStallMs = 10000;
|
||||
|
||||
int readRaw(uint8_t* into, size_t len); // from the connection, waiting up to kStallMs
|
||||
std::string request(const std::string& host, const std::string& path, const char* accept, const std::string& more, size_t needFree);
|
||||
|
||||
Counted<NetworkClientSecure> tls_;
|
||||
release::HttpHead head_;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <ctime>
|
||||
|
||||
#include "http_head.h"
|
||||
#include "resume.h"
|
||||
#include "platform/https_get.h"
|
||||
#include "platform/ota_device.h"
|
||||
#include "platform/system_info.h"
|
||||
@@ -64,21 +65,61 @@ class FileSource : public UpdateSource {
|
||||
File& f_;
|
||||
};
|
||||
|
||||
// A release being downloaded from Gitea: the same bytes a push or a card would give.
|
||||
// A release being downloaded from Gitea: the same bytes a push or a card would give. When the
|
||||
// connection breaks, it is opened again for the rest of the file (issue #54): what was written
|
||||
// to the slot stays, and the parser never sees the break.
|
||||
class GiteaSource : public UpdateSource {
|
||||
public:
|
||||
GiteaSource(HttpsGet& get, long cutAfter, long flipAt) : get_(get), cut_(cutAfter), flip_(flipAt) {}
|
||||
static constexpr uint32_t kWifiWaitMs = 60000; // for Wi-Fi to come back, before each try
|
||||
static constexpr uint32_t kPauseMs = 2000;
|
||||
|
||||
GiteaSource(HttpsGet& get, const release::Url& url, WifiService& wifi, long cutAfter, long flipAt)
|
||||
: get_(get), url_(url), wifi_(wifi), total_(get.contentLength()), etag_(get.head().etag), cut_(cutAfter), flip_(flipAt) {}
|
||||
|
||||
int read(uint8_t* buf, size_t len) override {
|
||||
if (cut_ >= 0 && pos_ >= cut_) return -1; // Debug Builds: the connection "breaks" here
|
||||
int n = get_.read(buf, len);
|
||||
for (;;) {
|
||||
// Debug: each connection "breaks" after cut_ bytes.
|
||||
int n = cut_ >= 0 && pos_ - openedAt_ >= cut_ ? -1 : get_.read(buf, len);
|
||||
if (n > 0 && flip_ >= pos_ && flip_ < pos_ + n) buf[flip_ - pos_] ^= 1; // and a byte "arrives wrong"
|
||||
if (n > 0) pos_ += n;
|
||||
return n;
|
||||
if (n >= 0) return n;
|
||||
if (!resume()) return -1;
|
||||
}
|
||||
}
|
||||
int resumes() const { return policy_.resumes(); }
|
||||
const std::string& why() const { return why_; } // why it gave up, when it did
|
||||
|
||||
private:
|
||||
bool resume() {
|
||||
// Without a length there is no knowing what "the rest" is.
|
||||
while (total_ > 0 && pos_ < total_ && policy_.again(pos_)) {
|
||||
get_.close();
|
||||
delay(kPauseMs);
|
||||
uint32_t since = millis();
|
||||
while (wifi_.state() != WifiController::State::Connected && millis() - since < kWifiWaitMs) delay(250);
|
||||
if (wifi_.state() != WifiController::State::Connected) {
|
||||
why_ = "Wi-Fi did not come back";
|
||||
continue;
|
||||
}
|
||||
why_ = get_.openFrom(url_.host, url_.path, "application/octet-stream", pos_, release::ifRangeFor(etag_));
|
||||
if (!why_.empty()) continue; // no connection yet: another try, while the policy allows
|
||||
why_ = release::resumeRefusal(get_.head(), pos_, total_, etag_);
|
||||
if (!why_.empty()) return false; // it answered, and it is not the rest of this file
|
||||
openedAt_ = pos_;
|
||||
ESP_LOGW("update", "carrying on from byte %ld of %ld (try %d)", pos_, total_, policy_.resumes());
|
||||
return true;
|
||||
}
|
||||
if (why_.empty()) why_ = "The download broke";
|
||||
return false;
|
||||
}
|
||||
|
||||
HttpsGet& get_;
|
||||
long cut_, flip_, pos_ = 0;
|
||||
release::Url url_;
|
||||
WifiService& wifi_;
|
||||
long total_;
|
||||
std::string etag_, why_;
|
||||
release::ResumePolicy policy_;
|
||||
long cut_, flip_, pos_ = 0, openedAt_ = 0;
|
||||
};
|
||||
|
||||
constexpr time_t kClockSetAfter = 1700000000; // anything earlier is the clock's default
|
||||
@@ -94,6 +135,26 @@ void UpdateService::notify(const std::string& text, NotificationLevel level) {
|
||||
bus_.publish(Event::withText(EventType::Notification, text.c_str(), static_cast<int32_t>(level)));
|
||||
}
|
||||
|
||||
// A line in the record of updates on the card (issue #52): the firmware can change without
|
||||
// anyone looking, so what was installed and when is written down.
|
||||
void UpdateService::record(const std::string& what) {
|
||||
time_t now = time(nullptr);
|
||||
char when[24] = "undated";
|
||||
if (now >= kClockSetAfter) {
|
||||
struct tm t;
|
||||
gmtime_r(&now, &t);
|
||||
strftime(when, sizeof when, "%Y-%m-%d %H:%M UTC", &t);
|
||||
}
|
||||
storage_.appendLine(kRecordPath, std::string(when) + " " + what);
|
||||
}
|
||||
|
||||
bool UpdateService::takeOffer(std::string& tag) {
|
||||
if (!offer_) return false;
|
||||
offer_ = false;
|
||||
tag = offered_;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string UpdateService::incomingVersion() const { return incoming_; }
|
||||
|
||||
void UpdateService::start() {
|
||||
@@ -108,6 +169,8 @@ void UpdateService::start() {
|
||||
store_.getString("ota_from", from);
|
||||
if (!pending.empty() && pending != versionString()) {
|
||||
notify("Update to " + pending + " failed, back on " + versionString(), NotificationLevel::Warning);
|
||||
record(pending + " failed and was undone: back on " + versionString());
|
||||
store_.putString("ota_auto", "");
|
||||
store_.putString("ota_failed", pending); // not announced again until there's a newer one (Q168)
|
||||
store_.putString("ota_pending", "");
|
||||
}
|
||||
@@ -142,6 +205,14 @@ void UpdateService::tick(uint32_t nowMs) {
|
||||
store_.putString("ota_pending", "");
|
||||
store_.putInt("ota_attempts", 0);
|
||||
notify(std::string("Updated to ") + versionString(), NotificationLevel::Info);
|
||||
{
|
||||
std::string from, automatic;
|
||||
store_.getString("ota_from", from);
|
||||
store_.getString("ota_auto", automatic);
|
||||
record(std::string(versionString()) + " installed" + (automatic == versionString() ? " by itself" : "") +
|
||||
(from.empty() ? "" : ", from " + from));
|
||||
store_.putString("ota_auto", "");
|
||||
}
|
||||
break;
|
||||
case Probation::Verdict::RollBack:
|
||||
// ota_pending still names this version: the previous firmware will report the failure.
|
||||
@@ -215,12 +286,13 @@ std::string UpdateService::failedVersion() const {
|
||||
return failed;
|
||||
}
|
||||
|
||||
std::string UpdateService::requestInstall(const std::string& tag) {
|
||||
std::string UpdateService::requestInstall(const std::string& tag, bool automatic) {
|
||||
if (phase_ != Phase::Idle || giteaBusy()) return "Busy with something else";
|
||||
if (wifi_.state() != WifiController::State::Connected) return "No Wi-Fi";
|
||||
release::Release r;
|
||||
if (!gitea_.find(tag, r)) return "Look for releases first";
|
||||
if (!release::trustedAssetUrl(r.otaUrl)) return "That download isn't on the project's server";
|
||||
store_.putString("ota_auto", automatic ? tag : "");
|
||||
installTag_ = tag;
|
||||
request_ = Request::Install;
|
||||
return "";
|
||||
@@ -281,7 +353,14 @@ void UpdateService::serve() {
|
||||
time_t now = time(nullptr);
|
||||
if (now >= kClockSetAfter) store_.putInt("rel_day", static_cast<int32_t>(now / 86400));
|
||||
release::Release latest;
|
||||
if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
|
||||
// Installing by itself (issue #52): offered at each daily check while it is the newest,
|
||||
// so a "not now" lasts a day. The main loop asks on the screen when the moment is quiet.
|
||||
if (r == Request::BackgroundCheck && gitea_.latest(latest) && settings_.getBool(Setting::InstallUpdates) &&
|
||||
release::shouldInstall(latest, runningVersion(), failedVersion())) {
|
||||
announced_ = latest.tag;
|
||||
offered_ = latest.tag;
|
||||
offer_ = true;
|
||||
} else if (r == Request::BackgroundCheck && gitea_.latest(latest) &&
|
||||
release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) {
|
||||
announced_ = latest.tag;
|
||||
notify(latest.tag + " is out: Settings > System > Firmware", NotificationLevel::Info); // 48 bytes at most
|
||||
@@ -325,9 +404,12 @@ void UpdateService::installFromGitea(const release::Release& r) {
|
||||
notify("Update refused: " + why, NotificationLevel::Warning);
|
||||
return;
|
||||
}
|
||||
GiteaSource source(get, damageCut_, damageFlip_);
|
||||
GiteaSource source(get, url, wifi_, damageCut_, damageFlip_);
|
||||
damageCut_ = damageFlip_ = -1;
|
||||
install(source, "Gitea");
|
||||
resumes_ = source.resumes();
|
||||
// What the parser says after a break is only that the file was short: say why it broke.
|
||||
if (phase_ != Phase::Installed && !source.why().empty()) notify("Download gave up: " + source.why(), NotificationLevel::Warning);
|
||||
}
|
||||
|
||||
void UpdateService::taskEntry(void* self) { static_cast<UpdateService*>(self)->listen(); }
|
||||
|
||||
@@ -51,7 +51,12 @@ class UpdateService : public Service {
|
||||
void requestList() { request_ = Request::List; }
|
||||
// Downloads `tag` (a release known from the last check or list) into the inactive slot and
|
||||
// installs it. "" when it started, or why not.
|
||||
std::string requestInstall(const std::string& tag);
|
||||
// `automatic`: nobody asked for it (issue #52), and the record of updates says so.
|
||||
std::string requestInstall(const std::string& tag, bool automatic = false);
|
||||
// A release the daily check found that may be installed without being asked for (the setting
|
||||
// is on): true once for each find, with its tag. The main loop picks the moment.
|
||||
bool takeOffer(std::string& tag);
|
||||
static constexpr const char* kRecordPath = "/system/updates.log"; // what was installed, and when
|
||||
bool giteaBusy() const { return request_ != Request::None || serving_; }
|
||||
// Asked to make room for a TLS connection (R1, Q172): IRC steps aside while the Update Service
|
||||
// talks to Gitea. Set by the main loop; `true` to step aside, `false` to come back.
|
||||
@@ -66,6 +71,7 @@ class UpdateService : public Service {
|
||||
// (is its certificate accepted?), and a download cut short or with one byte flipped.
|
||||
void requestProbe(const std::string& host, const std::string& path) { probeHost_ = host; probePath_ = path; probeResult_ = "..."; request_ = Request::Probe; }
|
||||
std::string probeResult() const { return probeResult_; }
|
||||
int lastResumes() const { return resumes_; } // how often the last download from Gitea was carried on (issue #54)
|
||||
void damageNextDownload(long cutAfter, long flipAt) { damageCut_ = cutAfter; damageFlip_ = flipAt; }
|
||||
void forgetToday() { store_.putInt("rel_day", 0); nextCheckMs_ = 0; announced_.clear(); } // the daily check runs at the next tick
|
||||
|
||||
@@ -85,6 +91,7 @@ class UpdateService : public Service {
|
||||
void listen();
|
||||
void install(class UpdateSource& source, const char* via);
|
||||
void notify(const std::string& text, NotificationLevel level);
|
||||
void record(const std::string& what);
|
||||
|
||||
KeyValueStore& store_;
|
||||
WifiService& wifi_;
|
||||
@@ -103,11 +110,13 @@ class UpdateService : public Service {
|
||||
volatile Request request_ = Request::None;
|
||||
volatile bool serving_ = false;
|
||||
bool held_ = false; // IRC was asked to step aside, on this task
|
||||
std::string installTag_, fakeVersion_, announced_;
|
||||
std::string installTag_, fakeVersion_, announced_, offered_;
|
||||
volatile bool offer_ = false;
|
||||
bool dailyCheck_ = false;
|
||||
uint32_t nextCheckMs_ = 90000; // not before the device has settled
|
||||
std::string probeHost_, probePath_;
|
||||
volatile long damageCut_ = -1, damageFlip_ = -1;
|
||||
volatile int resumes_ = 0;
|
||||
std::string probeResult_;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,41 @@
|
||||
|
||||
namespace roro::icons {
|
||||
|
||||
const uint8_t games[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x03, 0xff, 0xff, 0xc0, // ......####################......
|
||||
0x03, 0xff, 0xff, 0xc0, // ......####################......
|
||||
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
|
||||
0x0c, 0x00, 0x00, 0x30, // ....##....................##....
|
||||
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
|
||||
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
|
||||
0x30, 0xc0, 0x03, 0x0c, // ..##....##............##....##..
|
||||
0x30, 0xc0, 0x03, 0x0c, // ..##....##............##....##..
|
||||
0x33, 0xf0, 0x0c, 0xcc, // ..##..######........##..##..##..
|
||||
0x33, 0xf0, 0x0c, 0xcc, // ..##..######........##..##..##..
|
||||
0x30, 0xc0, 0x03, 0x0c, // ..##....##............##....##..
|
||||
0x30, 0xc0, 0x03, 0x0c, // ..##....##............##....##..
|
||||
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
|
||||
0x30, 0x00, 0x00, 0x0c, // ..##........................##..
|
||||
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
|
||||
0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##..
|
||||
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
|
||||
0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##..
|
||||
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
|
||||
0x0f, 0xc0, 0x03, 0xf0, // ....######............######....
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
};
|
||||
|
||||
const uint8_t gemini[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
@@ -178,6 +213,41 @@ const uint8_t notes[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
};
|
||||
|
||||
const uint8_t pong[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x03, 0x00, 0x00, // ..............##................
|
||||
0x00, 0x03, 0x00, 0x00, // ..............##................
|
||||
0x3c, 0x00, 0x00, 0x00, // ..####..........................
|
||||
0x3c, 0x00, 0x00, 0x00, // ..####..........................
|
||||
0x3c, 0x03, 0x00, 0x00, // ..####........##................
|
||||
0x3c, 0x03, 0x00, 0x00, // ..####........##................
|
||||
0x3c, 0x00, 0x00, 0x00, // ..####..........................
|
||||
0x3c, 0x00, 0x00, 0x00, // ..####..........................
|
||||
0x3c, 0x03, 0x0f, 0x00, // ..####........##....####........
|
||||
0x3c, 0x03, 0x0f, 0x00, // ..####........##....####........
|
||||
0x3c, 0x00, 0x0f, 0x0f, // ..####..............####....####
|
||||
0x3c, 0x00, 0x0f, 0x0f, // ..####..............####....####
|
||||
0x00, 0x03, 0x00, 0x0f, // ..............##............####
|
||||
0x00, 0x03, 0x00, 0x0f, // ..............##............####
|
||||
0x00, 0x00, 0x00, 0x0f, // ............................####
|
||||
0x00, 0x00, 0x00, 0x0f, // ............................####
|
||||
0x00, 0x03, 0x00, 0x0f, // ..............##............####
|
||||
0x00, 0x03, 0x00, 0x0f, // ..............##............####
|
||||
0x00, 0x00, 0x00, 0x0f, // ............................####
|
||||
0x00, 0x00, 0x00, 0x0f, // ............................####
|
||||
0x00, 0x03, 0x00, 0x00, // ..............##................
|
||||
0x00, 0x03, 0x00, 0x00, // ..............##................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
};
|
||||
|
||||
const uint8_t settings[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
@@ -248,6 +318,41 @@ const uint8_t shell[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
};
|
||||
|
||||
const uint8_t snake[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x0f, 0xf0, // ....................########....
|
||||
0x00, 0x00, 0x0f, 0xf0, // ....................########....
|
||||
0x00, 0x00, 0x0c, 0xf0, // ....................##..####....
|
||||
0x00, 0x00, 0x0c, 0xf0, // ....................##..####....
|
||||
0x00, 0x00, 0x0f, 0xf0, // ....................########....
|
||||
0x00, 0x00, 0x0f, 0xf0, // ....................########....
|
||||
0x00, 0x00, 0x03, 0xc0, // ......................####......
|
||||
0x00, 0x00, 0x03, 0xc0, // ......................####......
|
||||
0x03, 0xff, 0xff, 0x00, // ......##################........
|
||||
0x03, 0xff, 0xff, 0x00, // ......##################........
|
||||
0x03, 0xc0, 0x00, 0x00, // ......####......................
|
||||
0x03, 0xc0, 0x00, 0x00, // ......####......................
|
||||
0x03, 0xc0, 0x00, 0x00, // ......####......................
|
||||
0x03, 0xc0, 0x00, 0x00, // ......####......................
|
||||
0x03, 0xff, 0xff, 0x00, // ......##################........
|
||||
0x03, 0xff, 0xff, 0x00, // ......##################........
|
||||
0x00, 0x00, 0x0f, 0x00, // ....................####........
|
||||
0x00, 0x00, 0x0f, 0x00, // ....................####........
|
||||
0x00, 0x00, 0x0f, 0x00, // ....................####........
|
||||
0x00, 0x00, 0x0f, 0x00, // ....................####........
|
||||
0x3f, 0xff, 0xfc, 0x00, // ..####################..........
|
||||
0x3f, 0xff, 0xfc, 0x00, // ..####################..........
|
||||
0x3c, 0x00, 0x00, 0x00, // ..####..........................
|
||||
0x3c, 0x00, 0x00, 0x00, // ..####..........................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
};
|
||||
|
||||
const uint8_t ssh[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
@@ -353,6 +458,41 @@ const uint8_t system[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
};
|
||||
|
||||
const uint8_t tetris[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x0f, 0xcf, 0xcf, 0xc0, // ....######..######..######......
|
||||
0x0f, 0xcf, 0xcf, 0xc0, // ....######..######..######......
|
||||
0x0f, 0xcf, 0xcf, 0xc0, // ....######..######..######......
|
||||
0x0f, 0xcf, 0xcf, 0xc0, // ....######..######..######......
|
||||
0x0f, 0xcf, 0xcf, 0xc0, // ....######..######..######......
|
||||
0x0f, 0xcf, 0xcf, 0xc0, // ....######..######..######......
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x0f, 0xc0, 0x00, // ............######..............
|
||||
0x00, 0x0f, 0xc0, 0x00, // ............######..............
|
||||
0x00, 0x0f, 0xc0, 0x00, // ............######..............
|
||||
0x00, 0x0f, 0xc0, 0x00, // ............######..............
|
||||
0x00, 0x0f, 0xc0, 0x00, // ............######..............
|
||||
0x00, 0x0f, 0xc0, 0x00, // ............######..............
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x0f, 0xc0, 0x0f, 0xc0, // ....######..........######......
|
||||
0x0f, 0xc0, 0x0f, 0xc0, // ....######..........######......
|
||||
0x0f, 0xc0, 0x0f, 0xc0, // ....######..........######......
|
||||
0x0f, 0xc0, 0x0f, 0xc0, // ....######..........######......
|
||||
0x0f, 0xc0, 0x0f, 0xc0, // ....######..........######......
|
||||
0x0f, 0xc0, 0x0f, 0xc0, // ....######..........######......
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x3f, 0xff, 0xff, 0xf0, // ..##########################....
|
||||
0x3f, 0xff, 0xff, 0xf0, // ..##########################....
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
};
|
||||
|
||||
const uint8_t wifi_tools[128] = {
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
0x00, 0x00, 0x00, 0x00, // ................................
|
||||
|
||||
@@ -8,16 +8,20 @@ namespace roro::icons {
|
||||
// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.
|
||||
constexpr int kSize = 32;
|
||||
|
||||
extern const uint8_t games[128]; // games.png
|
||||
extern const uint8_t gemini[128]; // gemini.png
|
||||
extern const uint8_t gnss[128]; // gnss.png
|
||||
extern const uint8_t irc[128]; // irc.png
|
||||
extern const uint8_t lora[128]; // lora.png
|
||||
extern const uint8_t notes[128]; // notes.png
|
||||
extern const uint8_t pong[128]; // pong.png
|
||||
extern const uint8_t settings[128]; // settings.png
|
||||
extern const uint8_t shell[128]; // shell.png
|
||||
extern const uint8_t snake[128]; // snake.png
|
||||
extern const uint8_t ssh[128]; // ssh.png
|
||||
extern const uint8_t storage[128]; // storage.png
|
||||
extern const uint8_t system[128]; // system.png
|
||||
extern const uint8_t tetris[128]; // tetris.png
|
||||
extern const uint8_t wifi_tools[128]; // wifi-tools.png
|
||||
|
||||
} // namespace roro::icons
|
||||
|
||||
@@ -12,12 +12,12 @@ bool Screen::begin() {
|
||||
return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr;
|
||||
}
|
||||
|
||||
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
|
||||
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question) {
|
||||
const auto& area = theme::kContent;
|
||||
App& app = apps.foreground();
|
||||
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
|
||||
// or a Toast or the help panel, drawn over it, has just gone.
|
||||
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent();
|
||||
bool overlay = apps.help().isOpen() || toast || question, retains = app.retainsContent();
|
||||
if (overlayWas_ && !overlay) lost_ = true;
|
||||
if (retains && &app == lastApp_ && !lost_) {
|
||||
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
|
||||
@@ -31,6 +31,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
|
||||
canvas_.setClipRect(area.x, area.y, area.w, area.h);
|
||||
app.draw(canvas_);
|
||||
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
|
||||
if (question) widgets::dialog(canvas_, question->title, question->message, *question->dialog);
|
||||
canvas_.clearClipRect();
|
||||
widgets::statusBar(canvas_, status);
|
||||
if (toast) widgets::toast(canvas_, *toast);
|
||||
|
||||
@@ -10,7 +10,12 @@ namespace roro {
|
||||
class Screen {
|
||||
public:
|
||||
bool begin(); // allocates the frame buffer (~32 KB at 8-bit colour)
|
||||
void render(AppManager& apps, const StatusInfo& status, const Toast* toast);
|
||||
// A question the device itself asks, over whatever App is open (issue #52).
|
||||
struct Question {
|
||||
std::string title, message;
|
||||
const DialogModel* dialog;
|
||||
};
|
||||
void render(AppManager& apps, const StatusInfo& status, const Toast* toast, const Question* question = nullptr);
|
||||
// Takes over the screen while a Firmware Update is received or about to restart.
|
||||
void renderUpdate(const std::string& title, const std::string& detail, int percent);
|
||||
Canvas& canvas() { return canvas_; }
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "auto_update.h"
|
||||
#include "update_check.h"
|
||||
|
||||
using namespace roro::release;
|
||||
using Action = AutoUpdate::Action;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kIdle = AutoUpdate::kIdleMs, kAsk = AutoUpdate::kAskMs;
|
||||
Release release(const char* tag) {
|
||||
Release r;
|
||||
r.tag = tag;
|
||||
r.otaUrl = "https://git.twis.la/twisla/roro9stack/releases/download/x/x.ota";
|
||||
r.otaSize = 1;
|
||||
return r;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void test_nothing_happens_without_an_offer() {
|
||||
AutoUpdate a;
|
||||
TEST_ASSERT_TRUE(a.step(1000, false, kIdle) == Action::None);
|
||||
TEST_ASSERT_FALSE(a.waiting());
|
||||
TEST_ASSERT_FALSE(a.asking());
|
||||
}
|
||||
|
||||
void test_it_waits_for_a_quiet_moment() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
TEST_ASSERT_TRUE(a.waiting());
|
||||
TEST_ASSERT_TRUE(a.step(1000, false, kIdle - 1) == Action::None); // a key not long ago
|
||||
TEST_ASSERT_TRUE(a.step(2000, true, kIdle * 10) == Action::None); // a Track is recording
|
||||
TEST_ASSERT_TRUE(a.step(3000, false, kIdle) == Action::Ask);
|
||||
TEST_ASSERT_TRUE(a.asking());
|
||||
TEST_ASSERT_EQUAL_STRING("v1.2.0", a.tag().c_str());
|
||||
}
|
||||
|
||||
void test_unanswered_it_installs_after_30_seconds() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
a.step(5000, false, kIdle);
|
||||
TEST_ASSERT_EQUAL(30, a.secondsLeft(5000));
|
||||
TEST_ASSERT_EQUAL(30, a.secondsLeft(5001));
|
||||
TEST_ASSERT_EQUAL(1, a.secondsLeft(5000 + kAsk - 1));
|
||||
TEST_ASSERT_TRUE(a.step(5000 + kAsk - 1, false, kIdle) == Action::None);
|
||||
TEST_ASSERT_TRUE(a.step(5000 + kAsk, false, kIdle) == Action::Install);
|
||||
TEST_ASSERT_FALSE(a.asking());
|
||||
TEST_ASSERT_TRUE(a.step(5000 + kAsk + 1, false, kIdle) == Action::None); // once
|
||||
}
|
||||
|
||||
void test_accept_installs_at_once() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
a.step(0, false, kIdle);
|
||||
a.answer(true);
|
||||
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Install);
|
||||
}
|
||||
|
||||
void test_cancel_drops_it_until_the_next_offer() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
a.step(0, false, kIdle);
|
||||
a.answer(false);
|
||||
TEST_ASSERT_TRUE(a.step(100, false, 0) == Action::Dismiss);
|
||||
TEST_ASSERT_TRUE(a.step(kAsk * 2, false, kIdle * 2) == Action::None);
|
||||
a.offer("v1.2.0"); // the next day's check
|
||||
TEST_ASSERT_TRUE(a.step(kAsk * 3, false, kIdle) == Action::Ask);
|
||||
}
|
||||
|
||||
void test_something_starting_takes_the_question_down() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
a.step(0, false, kIdle);
|
||||
TEST_ASSERT_TRUE(a.step(1000, true, kIdle) == Action::Dismiss);
|
||||
TEST_ASSERT_TRUE(a.waiting());
|
||||
TEST_ASSERT_TRUE(a.step(2000, true, kIdle) == Action::None);
|
||||
TEST_ASSERT_TRUE(a.step(90000, false, kIdle) == Action::Ask); // and it comes back, with a full count
|
||||
TEST_ASSERT_EQUAL(30, a.secondsLeft(90000));
|
||||
}
|
||||
|
||||
void test_the_setting_going_off_withdraws_it() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
a.withdraw();
|
||||
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::None);
|
||||
TEST_ASSERT_FALSE(a.waiting());
|
||||
a.offer("v1.2.0");
|
||||
a.step(0, false, kIdle);
|
||||
a.withdraw();
|
||||
TEST_ASSERT_TRUE(a.step(10, false, kIdle) == Action::Dismiss);
|
||||
TEST_ASSERT_FALSE(a.asking());
|
||||
}
|
||||
|
||||
void test_an_answer_is_only_for_a_question() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
a.answer(true); // nothing was asked yet
|
||||
TEST_ASSERT_TRUE(a.step(0, false, kIdle) == Action::Ask);
|
||||
TEST_ASSERT_TRUE(a.step(1, false, kIdle) == Action::None);
|
||||
}
|
||||
|
||||
void test_a_newer_offer_replaces_the_tag() {
|
||||
AutoUpdate a;
|
||||
a.offer("v1.2.0");
|
||||
a.offer("v1.3.0");
|
||||
a.step(0, false, kIdle);
|
||||
TEST_ASSERT_EQUAL_STRING("v1.3.0", a.tag().c_str());
|
||||
}
|
||||
|
||||
void test_what_is_offered() {
|
||||
// Newer than what runs, and not a version that rolled back here.
|
||||
TEST_ASSERT_TRUE(shouldInstall(release("v1.2.0"), "v1.1.0", ""));
|
||||
TEST_ASSERT_FALSE(shouldInstall(release("v1.1.0"), "v1.1.0", ""));
|
||||
TEST_ASSERT_FALSE(shouldInstall(release("v1.0.0"), "v1.1.0", ""));
|
||||
TEST_ASSERT_FALSE(shouldInstall(release("v1.2.0"), "v1.1.0", "v1.2.0"));
|
||||
Release noFile = release("v1.2.0");
|
||||
noFile.otaUrl.clear();
|
||||
TEST_ASSERT_FALSE(shouldInstall(noFile, "v1.1.0", ""));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_nothing_happens_without_an_offer);
|
||||
RUN_TEST(test_it_waits_for_a_quiet_moment);
|
||||
RUN_TEST(test_unanswered_it_installs_after_30_seconds);
|
||||
RUN_TEST(test_accept_installs_at_once);
|
||||
RUN_TEST(test_cancel_drops_it_until_the_next_offer);
|
||||
RUN_TEST(test_something_starting_takes_the_question_down);
|
||||
RUN_TEST(test_the_setting_going_off_withdraws_it);
|
||||
RUN_TEST(test_an_answer_is_only_for_a_question);
|
||||
RUN_TEST(test_a_newer_offer_replaces_the_tag);
|
||||
RUN_TEST(test_what_is_offered);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <set>
|
||||
|
||||
#include "pong.h"
|
||||
#include "snake.h"
|
||||
#include "tetris.h"
|
||||
#include "tune.h"
|
||||
|
||||
using namespace roro::games;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
// ---- Pong ----
|
||||
|
||||
namespace {
|
||||
// Plays until the phase or an event says so; the player follows the ball if `follow`.
|
||||
uint8_t playPong(Pong& p, bool follow, uint8_t until, int maxSteps = 200000) {
|
||||
for (int i = 0; i < maxSteps; i++) {
|
||||
int move = !follow ? 0 : p.ballY() < p.playerY() - 2 ? -1 : p.ballY() > p.playerY() + 2 ? 1 : 0;
|
||||
uint8_t e = p.step(0.01f, move);
|
||||
if (e & until) return e;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void test_pong_starts_with_a_serve_from_the_middle() {
|
||||
Pong p(1);
|
||||
TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Serve);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kWidth / 2, p.ballX());
|
||||
p.step(Pong::kServePause / 2, 0);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kWidth / 2, p.ballX()); // it waits
|
||||
for (int i = 0; i < 100; i++) p.step(0.01f, 0);
|
||||
TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Play);
|
||||
p.step(0.1f, 0);
|
||||
TEST_ASSERT_TRUE(std::fabs(p.ballX() - Pong::kWidth / 2) > 5);
|
||||
}
|
||||
|
||||
void test_pong_the_same_seed_is_the_same_game() {
|
||||
Pong a(42), b(42), c(43);
|
||||
for (int i = 0; i < 500; i++) {
|
||||
a.step(0.01f, i % 3 - 1);
|
||||
b.step(0.01f, i % 3 - 1);
|
||||
c.step(0.01f, i % 3 - 1);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_FLOAT(a.ballX(), b.ballX());
|
||||
TEST_ASSERT_EQUAL_FLOAT(a.ballY(), b.ballY());
|
||||
TEST_ASSERT_TRUE(a.ballY() != c.ballY() || a.ballX() != c.ballX());
|
||||
}
|
||||
|
||||
void test_pong_the_ball_stays_between_the_walls() {
|
||||
Pong p(7);
|
||||
bool wall = false;
|
||||
for (int i = 0; i < 20000; i++) {
|
||||
uint8_t e = p.step(0.01f, p.ballY() < p.playerY() ? -1 : 1);
|
||||
wall |= (e & Pong::Wall) != 0;
|
||||
TEST_ASSERT_TRUE(p.ballY() >= 0 && p.ballY() <= Pong::kHeight);
|
||||
if (p.phase() == Pong::Phase::Over) p.start();
|
||||
}
|
||||
TEST_ASSERT_TRUE(wall);
|
||||
}
|
||||
|
||||
void test_pong_a_paddle_stays_on_the_field() {
|
||||
Pong p(3);
|
||||
for (int i = 0; i < 300; i++) p.step(0.01f, -1);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kPaddleH / 2, p.playerY());
|
||||
for (int i = 0; i < 300; i++) p.step(0.01f, 1);
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kHeight - Pong::kPaddleH / 2, p.playerY());
|
||||
}
|
||||
|
||||
void test_pong_a_return_counts_and_speeds_the_ball_up() {
|
||||
Pong p(5);
|
||||
float served = Pong::kServeSpeed;
|
||||
// Follow the ball until the player returns it once.
|
||||
int before = p.rally();
|
||||
for (int i = 0; i < 100000 && p.rally() == before; i++) {
|
||||
int move = p.ballY() < p.playerY() - 2 ? -1 : p.ballY() > p.playerY() + 2 ? 1 : 0;
|
||||
p.step(0.01f, move);
|
||||
}
|
||||
TEST_ASSERT_EQUAL(1, p.rally());
|
||||
TEST_ASSERT_EQUAL(1, p.longestRally());
|
||||
TEST_ASSERT_TRUE(p.speed() > served);
|
||||
TEST_ASSERT_TRUE(p.speed() <= Pong::kMaxSpeed);
|
||||
}
|
||||
|
||||
void test_pong_a_miss_is_a_point_for_the_other_side() {
|
||||
Pong p(9);
|
||||
// The player never moves from the top corner: sooner or later the ball gets past.
|
||||
for (int i = 0; i < 300; i++) p.step(0.01f, -1);
|
||||
while (p.deviceScore() == 0 && p.phase() != Pong::Phase::Over) p.step(0.01f, -1);
|
||||
TEST_ASSERT_EQUAL(1, p.deviceScore());
|
||||
TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Serve); // served again, from the middle
|
||||
TEST_ASSERT_FLOAT_WITHIN(0.01, Pong::kWidth / 2, p.ballX());
|
||||
TEST_ASSERT_EQUAL(0, p.rally());
|
||||
}
|
||||
|
||||
void test_pong_ends_at_seven() {
|
||||
Pong p(11);
|
||||
uint8_t e = playPong(p, false, Pong::Over); // a player who does nothing loses
|
||||
TEST_ASSERT_TRUE(e & Pong::Over);
|
||||
TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Over);
|
||||
TEST_ASSERT_EQUAL(Pong::kWinScore, std::max(p.playerScore(), p.deviceScore()));
|
||||
TEST_ASSERT_EQUAL(0, p.step(1.0f, 1)); // nothing moves once it is over
|
||||
p.start();
|
||||
TEST_ASSERT_EQUAL(0, p.deviceScore());
|
||||
TEST_ASSERT_TRUE(p.phase() == Pong::Phase::Serve);
|
||||
}
|
||||
|
||||
void test_pong_the_device_can_be_beaten() {
|
||||
// A player who always meets the ball: the device, slower than a steep fast ball, must lose
|
||||
// points over a few games.
|
||||
int playerPoints = 0;
|
||||
for (uint32_t seed = 1; seed <= 5; seed++) {
|
||||
Pong p(seed);
|
||||
playPong(p, true, Pong::Over, 2000000);
|
||||
playerPoints += p.playerScore();
|
||||
}
|
||||
TEST_ASSERT_TRUE(playerPoints > 0);
|
||||
}
|
||||
|
||||
void test_pong_the_device_paddle_has_a_top_speed() {
|
||||
Pong p(13);
|
||||
float before = p.deviceY();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
p.step(0.01f, 0);
|
||||
TEST_ASSERT_TRUE(std::fabs(p.deviceY() - before) <= Pong::kDeviceSpeed * 0.01f + 0.01f);
|
||||
before = p.deviceY();
|
||||
if (p.phase() == Pong::Phase::Over) p.start();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Snake ----
|
||||
|
||||
namespace {
|
||||
void moves(Snake& s, int n) {
|
||||
for (int i = 0; i < n && s.alive(); i++) s.step(s.intervalMs());
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void test_snake_starts_in_the_middle_three_long() {
|
||||
Snake s(1);
|
||||
TEST_ASSERT_TRUE(s.alive());
|
||||
TEST_ASSERT_EQUAL(3, s.body().size());
|
||||
TEST_ASSERT_EQUAL(Snake::kCols / 2, s.body().front().col);
|
||||
TEST_ASSERT_EQUAL(Snake::kRows / 2, s.body().front().row);
|
||||
TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Right);
|
||||
for (auto& c : s.body()) TEST_ASSERT_FALSE(c == s.food());
|
||||
}
|
||||
|
||||
void test_snake_moves_at_its_pace() {
|
||||
Snake s(1);
|
||||
s.putFood({0, 0});
|
||||
TEST_ASSERT_EQUAL(0, s.step(Snake::kSlowestMs - 1));
|
||||
TEST_ASSERT_EQUAL(Snake::Moved, s.step(1));
|
||||
TEST_ASSERT_EQUAL(Snake::kCols / 2 + 1, s.body().front().col);
|
||||
TEST_ASSERT_EQUAL(3, s.body().size());
|
||||
}
|
||||
|
||||
void test_snake_turns_but_not_back_on_itself() {
|
||||
Snake s(1);
|
||||
s.putFood({0, 0});
|
||||
s.turn(Snake::Dir::Left); // straight back: not a turn
|
||||
moves(s, 1);
|
||||
TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Right);
|
||||
s.turn(Snake::Dir::Up);
|
||||
moves(s, 1);
|
||||
TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Up);
|
||||
TEST_ASSERT_EQUAL(Snake::kRows / 2 - 1, s.body().front().row);
|
||||
}
|
||||
|
||||
void test_snake_remembers_a_quick_corner() {
|
||||
Snake s(1);
|
||||
s.putFood({0, 0});
|
||||
s.turn(Snake::Dir::Up); // both before the next move
|
||||
s.turn(Snake::Dir::Left); // would be "back" for a snake heading right, but not after the first
|
||||
s.turn(Snake::Dir::Down); // a third is not remembered
|
||||
moves(s, 1);
|
||||
TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Up);
|
||||
moves(s, 1);
|
||||
TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Left);
|
||||
moves(s, 1);
|
||||
TEST_ASSERT_TRUE(s.heading() == Snake::Dir::Left);
|
||||
TEST_ASSERT_TRUE(s.alive());
|
||||
}
|
||||
|
||||
void test_snake_eats_grows_and_gets_new_food() {
|
||||
Snake s(1);
|
||||
Snake::Cell head = s.body().front();
|
||||
s.putFood({static_cast<int8_t>(head.col + 1), head.row});
|
||||
uint8_t e = s.step(s.intervalMs());
|
||||
TEST_ASSERT_TRUE(e & Snake::Ate);
|
||||
TEST_ASSERT_EQUAL(1, s.score());
|
||||
TEST_ASSERT_EQUAL(4, s.body().size());
|
||||
for (auto& c : s.body()) TEST_ASSERT_FALSE(c == s.food());
|
||||
TEST_ASSERT_TRUE(s.food().col >= 0 && s.food().col < Snake::kCols && s.food().row >= 0 && s.food().row < Snake::kRows);
|
||||
}
|
||||
|
||||
void test_snake_dies_at_a_wall() {
|
||||
Snake s(1);
|
||||
s.putFood({0, 0});
|
||||
uint8_t last = 0;
|
||||
for (int i = 0; i < Snake::kCols && s.alive(); i++) last = s.step(s.intervalMs());
|
||||
TEST_ASSERT_FALSE(s.alive());
|
||||
TEST_ASSERT_TRUE(last & Snake::Died);
|
||||
TEST_ASSERT_EQUAL(Snake::kCols - 1, s.body().front().col); // it stopped at the edge
|
||||
TEST_ASSERT_EQUAL(3, s.body().size()); // whole
|
||||
TEST_ASSERT_EQUAL(0, s.step(1000));
|
||||
}
|
||||
|
||||
void test_snake_dies_on_itself() {
|
||||
Snake s(1);
|
||||
// Fed four times in a row, it is long enough to bite itself in a tight turn.
|
||||
for (int i = 0; i < 4; i++) {
|
||||
Snake::Cell head = s.body().front();
|
||||
s.putFood({static_cast<int8_t>(head.col + 1), head.row});
|
||||
s.step(s.intervalMs());
|
||||
}
|
||||
s.putFood({0, 0});
|
||||
TEST_ASSERT_EQUAL(7, s.body().size());
|
||||
s.turn(Snake::Dir::Down);
|
||||
moves(s, 1);
|
||||
s.turn(Snake::Dir::Left);
|
||||
moves(s, 1);
|
||||
s.turn(Snake::Dir::Up);
|
||||
moves(s, 1);
|
||||
TEST_ASSERT_FALSE(s.alive());
|
||||
}
|
||||
|
||||
void test_snake_may_follow_its_own_tail() {
|
||||
Snake s(1);
|
||||
Snake::Cell head = s.body().front();
|
||||
s.putFood({static_cast<int8_t>(head.col + 1), head.row});
|
||||
s.step(s.intervalMs()); // four long: a square of 2 x 2 is its own tail chased
|
||||
s.putFood({0, 0});
|
||||
for (auto d : {Snake::Dir::Down, Snake::Dir::Left, Snake::Dir::Up, Snake::Dir::Right, Snake::Dir::Down}) {
|
||||
s.turn(d);
|
||||
moves(s, 1);
|
||||
}
|
||||
TEST_ASSERT_TRUE(s.alive());
|
||||
}
|
||||
|
||||
void test_snake_gets_faster_as_it_eats() {
|
||||
Snake s(1);
|
||||
uint32_t start = s.intervalMs();
|
||||
TEST_ASSERT_EQUAL(Snake::kSlowestMs, start);
|
||||
for (int i = 0; i < Snake::kFoodsAStep; i++) {
|
||||
Snake::Cell head = s.body().front();
|
||||
s.putFood({static_cast<int8_t>(head.col + 1), head.row});
|
||||
s.step(s.intervalMs());
|
||||
}
|
||||
TEST_ASSERT_EQUAL(start - Snake::kFasterMs, s.intervalMs());
|
||||
}
|
||||
|
||||
void test_snake_the_same_seed_puts_the_food_in_the_same_places() {
|
||||
Snake a(77), b(77), c(78);
|
||||
TEST_ASSERT_TRUE(a.food() == b.food());
|
||||
bool differs = !(a.food() == c.food());
|
||||
for (int i = 0; i < 3; i++) {
|
||||
for (Snake* s : {&a, &b, &c}) {
|
||||
Snake::Cell head = s->body().front();
|
||||
s->putFood({static_cast<int8_t>(head.col + 1), head.row});
|
||||
s->step(s->intervalMs());
|
||||
}
|
||||
TEST_ASSERT_TRUE(a.food() == b.food());
|
||||
differs |= !(a.food() == c.food());
|
||||
}
|
||||
TEST_ASSERT_TRUE(differs);
|
||||
}
|
||||
|
||||
// ---- Tetris ----
|
||||
|
||||
namespace {
|
||||
const TetrisInput kNothing;
|
||||
TetrisInput press(bool TetrisInput::*key) {
|
||||
TetrisInput in;
|
||||
in.*key = true;
|
||||
return in;
|
||||
}
|
||||
int filled(const Tetris& t) {
|
||||
int n = 0;
|
||||
for (int r = 0; r < Tetris::kRows; r++)
|
||||
for (int c = 0; c < Tetris::kCols; c++) n += t.cell(c, r) != 0;
|
||||
return n;
|
||||
}
|
||||
// A row filled but for one column.
|
||||
void fillRow(Tetris& t, int row, int gap) {
|
||||
for (int c = 0; c < Tetris::kCols; c++)
|
||||
if (c != gap) t.put(c, row, 7);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void test_tetris_deals_each_piece_once_in_seven() {
|
||||
Tetris t(5);
|
||||
for (int bag = 0; bag < 4; bag++) {
|
||||
std::set<int> seen;
|
||||
for (int i = 0; i < 7; i++) {
|
||||
seen.insert(t.piece().kind);
|
||||
t.step(0, press(&TetrisInput::hardDrop));
|
||||
if (t.over()) t.start(), i = -1, seen.clear(); // start over on a full well
|
||||
}
|
||||
TEST_ASSERT_EQUAL(7, seen.size());
|
||||
t.start();
|
||||
}
|
||||
}
|
||||
|
||||
void test_tetris_shows_the_piece_that_comes_next() {
|
||||
Tetris t(9);
|
||||
uint8_t next = t.next();
|
||||
t.step(0, press(&TetrisInput::hardDrop));
|
||||
TEST_ASSERT_EQUAL(next, t.piece().kind);
|
||||
}
|
||||
|
||||
void test_tetris_a_piece_falls_at_the_levels_pace() {
|
||||
Tetris t(1);
|
||||
t.give(3); // a T
|
||||
int row = t.piece().row;
|
||||
TEST_ASSERT_EQUAL(0, t.step(Tetris::kSlowestMs - 1, kNothing));
|
||||
TEST_ASSERT_EQUAL(row, t.piece().row);
|
||||
TEST_ASSERT_EQUAL(Tetris::Moved, t.step(1, kNothing));
|
||||
TEST_ASSERT_EQUAL(row + 1, t.piece().row);
|
||||
}
|
||||
|
||||
void test_tetris_a_soft_drop_is_faster_and_scores() {
|
||||
Tetris t(1);
|
||||
t.give(3);
|
||||
int row = t.piece().row;
|
||||
t.step(Tetris::kSoftMs * 3, press(&TetrisInput::soft));
|
||||
TEST_ASSERT_EQUAL(row + 3, t.piece().row);
|
||||
TEST_ASSERT_EQUAL(3, t.score());
|
||||
}
|
||||
|
||||
void test_tetris_a_held_soft_drop_stops_with_its_piece() {
|
||||
Tetris t(1);
|
||||
t.give(2);
|
||||
TetrisInput soft = press(&TetrisInput::soft);
|
||||
uint8_t all = 0;
|
||||
for (int i = 0; i < 40 && !(all & Tetris::Locked); i++) all |= t.step(Tetris::kSoftMs, soft);
|
||||
TEST_ASSERT_TRUE(all & Tetris::Locked);
|
||||
// The key is still down: the next piece falls at the level's pace, not the fast one.
|
||||
int row = t.piece().row, score = t.score();
|
||||
t.step(Tetris::kSoftMs * 4, soft);
|
||||
TEST_ASSERT_EQUAL(row, t.piece().row);
|
||||
TEST_ASSERT_EQUAL(score, t.score());
|
||||
t.step(0, kNothing); // let go, and pressed again: it works
|
||||
t.step(Tetris::kSoftMs * 2, soft);
|
||||
TEST_ASSERT_TRUE(t.piece().row > row);
|
||||
}
|
||||
|
||||
void test_tetris_moves_stop_at_the_walls() {
|
||||
Tetris t(1);
|
||||
t.give(2); // an O: two wide
|
||||
for (int i = 0; i < 20; i++) {
|
||||
t.step(0, press(&TetrisInput::left));
|
||||
t.step(0, kNothing); // released: the next press is a press
|
||||
}
|
||||
int leftmost = Tetris::kCols;
|
||||
for (auto& c : Tetris::cells(t.piece())) leftmost = std::min<int>(leftmost, c.col);
|
||||
TEST_ASSERT_EQUAL(0, leftmost);
|
||||
for (int i = 0; i < 20; i++) {
|
||||
t.step(0, press(&TetrisInput::right));
|
||||
t.step(0, kNothing);
|
||||
}
|
||||
int rightmost = 0;
|
||||
for (auto& c : Tetris::cells(t.piece())) rightmost = std::max<int>(rightmost, c.col);
|
||||
TEST_ASSERT_EQUAL(Tetris::kCols - 1, rightmost);
|
||||
}
|
||||
|
||||
void test_tetris_a_held_key_repeats() {
|
||||
Tetris t(1);
|
||||
t.give(2);
|
||||
int col = t.piece().col;
|
||||
TetrisInput left = press(&TetrisInput::left);
|
||||
t.step(0, left); // the press: one step at once
|
||||
TEST_ASSERT_EQUAL(col - 1, t.piece().col);
|
||||
t.step(Tetris::kRepeatAfterMs - 1, left);
|
||||
TEST_ASSERT_EQUAL(col - 1, t.piece().col); // not yet
|
||||
t.step(1, left);
|
||||
TEST_ASSERT_EQUAL(col - 2, t.piece().col);
|
||||
t.step(Tetris::kRepeatEveryMs, left);
|
||||
TEST_ASSERT_EQUAL(col - 3, t.piece().col);
|
||||
TetrisInput both = left;
|
||||
both.right = true; // both held: neither
|
||||
t.step(Tetris::kRepeatEveryMs * 3, both);
|
||||
TEST_ASSERT_EQUAL(col - 3, t.piece().col);
|
||||
}
|
||||
|
||||
void test_tetris_turns_and_turns_back() {
|
||||
Tetris t(1);
|
||||
t.give(3);
|
||||
auto before = Tetris::cells(t.piece());
|
||||
TEST_ASSERT_EQUAL(Tetris::Turned, t.step(0, press(&TetrisInput::rotate)));
|
||||
TEST_ASSERT_EQUAL(1, t.piece().turn);
|
||||
t.step(0, press(&TetrisInput::rotateBack));
|
||||
TEST_ASSERT_EQUAL(0, t.piece().turn);
|
||||
auto after = Tetris::cells(t.piece());
|
||||
for (int i = 0; i < 4; i++) {
|
||||
TEST_ASSERT_EQUAL(before[i].col, after[i].col);
|
||||
TEST_ASSERT_EQUAL(before[i].row, after[i].row);
|
||||
}
|
||||
for (int i = 0; i < 4; i++) t.step(0, press(&TetrisInput::rotate));
|
||||
TEST_ASSERT_EQUAL(0, t.piece().turn); // four quarter turns: as it was
|
||||
}
|
||||
|
||||
void test_tetris_a_turn_against_a_wall_shifts_the_piece() {
|
||||
Tetris t(1);
|
||||
t.give(1); // the long one
|
||||
t.step(0, press(&TetrisInput::rotate)); // upright
|
||||
for (int i = 0; i < 10; i++) {
|
||||
t.step(0, press(&TetrisInput::right));
|
||||
t.step(0, kNothing);
|
||||
}
|
||||
for (auto& c : Tetris::cells(t.piece())) TEST_ASSERT_EQUAL(Tetris::kCols - 1, c.col); // against the right wall
|
||||
TEST_ASSERT_EQUAL(Tetris::Turned, t.step(0, press(&TetrisInput::rotate))); // flat again: it needs four columns
|
||||
for (auto& c : Tetris::cells(t.piece())) TEST_ASSERT_TRUE(c.col >= Tetris::kCols - 4 && c.col < Tetris::kCols);
|
||||
}
|
||||
|
||||
void test_tetris_a_turn_with_no_room_does_not_happen() {
|
||||
Tetris t(1);
|
||||
t.give(1);
|
||||
t.step(0, press(&TetrisInput::rotate));
|
||||
// Walled in on both sides, a column wide.
|
||||
int col = Tetris::cells(t.piece())[0].col;
|
||||
for (int r = 0; r < Tetris::kRows; r++)
|
||||
for (int c = 0; c < Tetris::kCols; c++)
|
||||
if (c != col) t.put(c, r, 7);
|
||||
TEST_ASSERT_EQUAL(0, t.step(0, press(&TetrisInput::rotate)));
|
||||
TEST_ASSERT_EQUAL(1, t.piece().turn);
|
||||
}
|
||||
|
||||
void test_tetris_a_hard_drop_lands_locks_and_scores() {
|
||||
Tetris t(1);
|
||||
t.give(2); // an O
|
||||
int from = t.piece().row;
|
||||
uint8_t e = t.step(0, press(&TetrisInput::hardDrop));
|
||||
TEST_ASSERT_TRUE(e & Tetris::Locked);
|
||||
TEST_ASSERT_EQUAL(4, filled(t));
|
||||
TEST_ASSERT_NOT_EQUAL(0, t.cell(4, Tetris::kRows - 1));
|
||||
TEST_ASSERT_NOT_EQUAL(0, t.cell(5, Tetris::kRows - 2));
|
||||
TEST_ASSERT_EQUAL(2 * (Tetris::kRows - 2 - from), t.score()); // two a row fallen
|
||||
TEST_ASSERT_EQUAL(0, t.lastCleared());
|
||||
}
|
||||
|
||||
void test_tetris_a_piece_locks_when_it_can_fall_no_more() {
|
||||
Tetris t(1);
|
||||
t.give(2);
|
||||
uint8_t all = 0;
|
||||
for (int i = 0; i < Tetris::kRows + 2 && !(all & Tetris::Locked); i++) all |= t.step(Tetris::kSlowestMs, kNothing);
|
||||
TEST_ASSERT_TRUE(all & Tetris::Locked);
|
||||
TEST_ASSERT_EQUAL(4, filled(t));
|
||||
}
|
||||
|
||||
void test_tetris_a_full_row_goes_and_the_rest_comes_down() {
|
||||
Tetris t(1);
|
||||
fillRow(t, Tetris::kRows - 1, 4); // the gap where the O will not fit: use the long one upright
|
||||
t.put(0, Tetris::kRows - 2, 5); // something above, to see it come down
|
||||
t.give(1);
|
||||
t.step(0, press(&TetrisInput::rotate)); // upright, in column 5
|
||||
t.step(0, press(&TetrisInput::left)); // column 4
|
||||
uint8_t e = t.step(0, press(&TetrisInput::hardDrop));
|
||||
TEST_ASSERT_TRUE(e & Tetris::Lines);
|
||||
TEST_ASSERT_EQUAL(1, t.lastCleared());
|
||||
TEST_ASSERT_EQUAL(1, t.lines());
|
||||
TEST_ASSERT_EQUAL(5, t.cell(0, Tetris::kRows - 1)); // what was above
|
||||
TEST_ASSERT_EQUAL(1, t.cell(4, Tetris::kRows - 1)); // and the rest of the long piece
|
||||
TEST_ASSERT_EQUAL(0, t.cell(1, Tetris::kRows - 1));
|
||||
TEST_ASSERT_EQUAL(1 + 3, filled(t));
|
||||
}
|
||||
|
||||
void test_tetris_four_rows_at_once_score_most() {
|
||||
Tetris t(1);
|
||||
for (int r = Tetris::kRows - 4; r < Tetris::kRows; r++) fillRow(t, r, 9);
|
||||
t.give(1);
|
||||
t.step(0, press(&TetrisInput::rotate));
|
||||
for (int i = 0; i < 10; i++) {
|
||||
t.step(0, press(&TetrisInput::right));
|
||||
t.step(0, kNothing);
|
||||
}
|
||||
int before = t.score();
|
||||
uint8_t e = t.step(0, press(&TetrisInput::hardDrop));
|
||||
TEST_ASSERT_TRUE(e & Tetris::Lines);
|
||||
TEST_ASSERT_EQUAL(4, t.lastCleared());
|
||||
TEST_ASSERT_EQUAL(0, filled(t));
|
||||
int dropped = (t.score() - before) - 800;
|
||||
TEST_ASSERT_TRUE(dropped > 0 && dropped <= 2 * Tetris::kRows); // 800 for the four, and the drop
|
||||
}
|
||||
|
||||
void test_tetris_ten_lines_are_a_level_and_a_faster_fall() {
|
||||
Tetris t(1);
|
||||
TEST_ASSERT_EQUAL(0, t.level());
|
||||
TEST_ASSERT_EQUAL(Tetris::kSlowestMs, t.fallMs());
|
||||
uint8_t all = 0;
|
||||
for (int i = 0; i < 3; i++) { // 4 + 4 + 4
|
||||
for (int r = Tetris::kRows - 4; r < Tetris::kRows; r++) fillRow(t, r, 9);
|
||||
t.give(1);
|
||||
t.step(0, press(&TetrisInput::rotate));
|
||||
for (int k = 0; k < 10; k++) {
|
||||
t.step(0, press(&TetrisInput::right));
|
||||
t.step(0, kNothing);
|
||||
}
|
||||
all |= t.step(0, press(&TetrisInput::hardDrop));
|
||||
}
|
||||
TEST_ASSERT_EQUAL(12, t.lines());
|
||||
TEST_ASSERT_EQUAL(1, t.level());
|
||||
TEST_ASSERT_TRUE(all & Tetris::LevelUp);
|
||||
TEST_ASSERT_EQUAL(Tetris::kSlowestMs * 85 / 100, t.fallMs());
|
||||
}
|
||||
|
||||
void test_tetris_the_fall_never_gets_faster_than_its_limit() {
|
||||
Tetris t(1);
|
||||
for (int i = 0; i < 80; i++) {
|
||||
for (int r = Tetris::kRows - 4; r < Tetris::kRows; r++) fillRow(t, r, 9);
|
||||
t.give(1);
|
||||
t.step(0, press(&TetrisInput::rotate));
|
||||
for (int k = 0; k < 10; k++) {
|
||||
t.step(0, press(&TetrisInput::right));
|
||||
t.step(0, kNothing);
|
||||
}
|
||||
t.step(0, press(&TetrisInput::hardDrop));
|
||||
}
|
||||
TEST_ASSERT_TRUE(t.level() >= 30);
|
||||
TEST_ASSERT_EQUAL(Tetris::kFastestMs, t.fallMs());
|
||||
}
|
||||
|
||||
void test_tetris_is_over_when_a_piece_has_no_room() {
|
||||
Tetris t(3);
|
||||
uint8_t last = 0;
|
||||
for (int i = 0; i < 40 && !t.over(); i++) last = t.step(0, press(&TetrisInput::hardDrop)); // all in the middle
|
||||
TEST_ASSERT_TRUE(t.over());
|
||||
TEST_ASSERT_TRUE(last & Tetris::Over);
|
||||
TEST_ASSERT_EQUAL(0, t.step(1000, press(&TetrisInput::hardDrop)));
|
||||
t.start();
|
||||
TEST_ASSERT_FALSE(t.over());
|
||||
TEST_ASSERT_EQUAL(0, filled(t));
|
||||
TEST_ASSERT_EQUAL(0, t.score());
|
||||
}
|
||||
|
||||
void test_tetris_the_same_seed_deals_the_same_pieces() {
|
||||
Tetris a(21), b(21), c(22);
|
||||
bool differs = false;
|
||||
for (int i = 0; i < 14; i++) {
|
||||
TEST_ASSERT_EQUAL(a.piece().kind, b.piece().kind);
|
||||
differs |= a.piece().kind != c.piece().kind;
|
||||
for (Tetris* t : {&a, &b, &c}) {
|
||||
t->step(0, press(&TetrisInput::hardDrop));
|
||||
if (t->over()) t->start();
|
||||
}
|
||||
}
|
||||
TEST_ASSERT_TRUE(differs);
|
||||
}
|
||||
|
||||
void test_tetris_every_piece_in_every_turn_has_four_cells_in_its_box() {
|
||||
for (uint8_t kind = 1; kind <= 7; kind++)
|
||||
for (uint8_t turn = 0; turn < 4; turn++) {
|
||||
Tetris::Piece p{kind, turn, 0, 0};
|
||||
std::set<int> where;
|
||||
for (auto& c : Tetris::cells(p)) {
|
||||
TEST_ASSERT_TRUE(c.col >= 0 && c.col < 4 && c.row >= 0 && c.row < 4);
|
||||
where.insert(c.row * 4 + c.col);
|
||||
}
|
||||
TEST_ASSERT_EQUAL(4, where.size());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- The tune ----
|
||||
|
||||
void test_a_tune_gives_its_notes_in_time() {
|
||||
static const Note kThree[] = {{440, 2}, {0, 1}, {880, 1}};
|
||||
TunePlayer p([](size_t i) { return kThree[i]; }, 3, 100);
|
||||
Note n{};
|
||||
uint32_t ms = 0;
|
||||
TEST_ASSERT_TRUE(p.step(0, n, ms)); // the first note at once
|
||||
TEST_ASSERT_EQUAL(440, n.hz);
|
||||
TEST_ASSERT_EQUAL(200, ms);
|
||||
TEST_ASSERT_FALSE(p.step(199, n, ms));
|
||||
TEST_ASSERT_TRUE(p.step(1, n, ms)); // a rest is a note too: the caller plays nothing
|
||||
TEST_ASSERT_EQUAL(0, n.hz);
|
||||
TEST_ASSERT_EQUAL(100, ms);
|
||||
TEST_ASSERT_TRUE(p.step(100, n, ms));
|
||||
TEST_ASSERT_EQUAL(880, n.hz);
|
||||
TEST_ASSERT_TRUE(p.step(100, n, ms)); // and round again
|
||||
TEST_ASSERT_EQUAL(440, n.hz);
|
||||
TEST_ASSERT_EQUAL(0, p.position());
|
||||
}
|
||||
|
||||
void test_a_tune_keeps_time_over_uneven_steps() {
|
||||
static const Note kTwo[] = {{440, 1}, {880, 1}};
|
||||
TunePlayer p([](size_t i) { return kTwo[i]; }, 2, 100);
|
||||
Note n{};
|
||||
uint32_t ms = 0;
|
||||
p.step(0, n, ms);
|
||||
int notes = 1;
|
||||
for (int i = 0; i < 300; i++) notes += p.step(33, n, ms); // 9.9 seconds in steps of 33 ms
|
||||
TEST_ASSERT_EQUAL(100, notes); // one every 100 ms: the 33s do not add up to a drift
|
||||
}
|
||||
|
||||
void test_a_tune_skips_what_a_long_gap_missed() {
|
||||
static const Note kFour[] = {{100, 1}, {200, 1}, {300, 1}, {400, 1}};
|
||||
TunePlayer p([](size_t i) { return kFour[i]; }, 4, 100);
|
||||
Note n{};
|
||||
uint32_t ms = 0;
|
||||
p.step(0, n, ms);
|
||||
TEST_ASSERT_TRUE(p.step(250, n, ms)); // 200 and half of 300 went by
|
||||
TEST_ASSERT_EQUAL(300, n.hz);
|
||||
TEST_ASSERT_FALSE(p.step(49, n, ms));
|
||||
TEST_ASSERT_TRUE(p.step(1, n, ms));
|
||||
TEST_ASSERT_EQUAL(400, n.hz);
|
||||
p.restart();
|
||||
TEST_ASSERT_TRUE(p.step(0, n, ms));
|
||||
TEST_ASSERT_EQUAL(100, n.hz);
|
||||
}
|
||||
|
||||
void test_korobeiniki_is_whole_bars() {
|
||||
// Each part is eight bars of eight eighths, and it begins on its famous E.
|
||||
int first = 0, second = 0;
|
||||
for (size_t i = 0; i < korobeiniki::kFirstCount; i++) first += korobeiniki::kFirst[i].eighths;
|
||||
for (size_t i = 0; i < korobeiniki::kSecondCount; i++) second += korobeiniki::kSecond[i].eighths;
|
||||
TEST_ASSERT_EQUAL(64, first);
|
||||
TEST_ASSERT_EQUAL(64, second);
|
||||
TEST_ASSERT_EQUAL(659, korobeiniki::at(0).hz);
|
||||
TEST_ASSERT_EQUAL(659, korobeiniki::at(korobeiniki::kFirstCount).hz); // the tune again
|
||||
TEST_ASSERT_EQUAL(659, korobeiniki::at(2 * korobeiniki::kFirstCount).hz); // then the slow part
|
||||
TEST_ASSERT_EQUAL(4, korobeiniki::at(2 * korobeiniki::kFirstCount).eighths);
|
||||
TEST_ASSERT_EQUAL(0, korobeiniki::at(korobeiniki::kCount - 1).hz); // and a breath before it starts over
|
||||
}
|
||||
|
||||
void test_the_tune_hurries_with_the_level() {
|
||||
TEST_ASSERT_EQUAL(200, korobeiniki::eighthMsAt(0));
|
||||
TEST_ASSERT_EQUAL(192, korobeiniki::eighthMsAt(1));
|
||||
TEST_ASSERT_EQUAL(160, korobeiniki::eighthMsAt(5));
|
||||
TEST_ASSERT_EQUAL(korobeiniki::kFastestEighthMs, korobeiniki::eighthMsAt(12));
|
||||
TEST_ASSERT_EQUAL(korobeiniki::kFastestEighthMs, korobeiniki::eighthMsAt(99));
|
||||
for (int level = 0; level < 20; level++) TEST_ASSERT_TRUE(korobeiniki::eighthMsAt(level + 1) <= korobeiniki::eighthMsAt(level));
|
||||
}
|
||||
|
||||
void test_a_new_pace_starts_at_the_next_note() {
|
||||
static const Note kTwo[] = {{440, 1}, {880, 1}};
|
||||
TunePlayer p([](size_t i) { return kTwo[i]; }, 2, 100);
|
||||
Note n{};
|
||||
uint32_t ms = 0;
|
||||
p.step(0, n, ms);
|
||||
p.setEighthMs(50);
|
||||
TEST_ASSERT_FALSE(p.step(99, n, ms)); // the note that had begun keeps its length
|
||||
TEST_ASSERT_TRUE(p.step(1, n, ms));
|
||||
TEST_ASSERT_EQUAL(50, ms);
|
||||
TEST_ASSERT_TRUE(p.step(50, n, ms));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_pong_starts_with_a_serve_from_the_middle);
|
||||
RUN_TEST(test_pong_the_same_seed_is_the_same_game);
|
||||
RUN_TEST(test_pong_the_ball_stays_between_the_walls);
|
||||
RUN_TEST(test_pong_a_paddle_stays_on_the_field);
|
||||
RUN_TEST(test_pong_a_return_counts_and_speeds_the_ball_up);
|
||||
RUN_TEST(test_pong_a_miss_is_a_point_for_the_other_side);
|
||||
RUN_TEST(test_pong_ends_at_seven);
|
||||
RUN_TEST(test_pong_the_device_can_be_beaten);
|
||||
RUN_TEST(test_pong_the_device_paddle_has_a_top_speed);
|
||||
RUN_TEST(test_snake_starts_in_the_middle_three_long);
|
||||
RUN_TEST(test_snake_moves_at_its_pace);
|
||||
RUN_TEST(test_snake_turns_but_not_back_on_itself);
|
||||
RUN_TEST(test_snake_remembers_a_quick_corner);
|
||||
RUN_TEST(test_snake_eats_grows_and_gets_new_food);
|
||||
RUN_TEST(test_snake_dies_at_a_wall);
|
||||
RUN_TEST(test_snake_dies_on_itself);
|
||||
RUN_TEST(test_snake_may_follow_its_own_tail);
|
||||
RUN_TEST(test_snake_gets_faster_as_it_eats);
|
||||
RUN_TEST(test_snake_the_same_seed_puts_the_food_in_the_same_places);
|
||||
RUN_TEST(test_tetris_deals_each_piece_once_in_seven);
|
||||
RUN_TEST(test_tetris_shows_the_piece_that_comes_next);
|
||||
RUN_TEST(test_tetris_a_piece_falls_at_the_levels_pace);
|
||||
RUN_TEST(test_tetris_a_soft_drop_is_faster_and_scores);
|
||||
RUN_TEST(test_tetris_a_held_soft_drop_stops_with_its_piece);
|
||||
RUN_TEST(test_tetris_moves_stop_at_the_walls);
|
||||
RUN_TEST(test_tetris_a_held_key_repeats);
|
||||
RUN_TEST(test_tetris_turns_and_turns_back);
|
||||
RUN_TEST(test_tetris_a_turn_against_a_wall_shifts_the_piece);
|
||||
RUN_TEST(test_tetris_a_turn_with_no_room_does_not_happen);
|
||||
RUN_TEST(test_tetris_a_hard_drop_lands_locks_and_scores);
|
||||
RUN_TEST(test_tetris_a_piece_locks_when_it_can_fall_no_more);
|
||||
RUN_TEST(test_tetris_a_full_row_goes_and_the_rest_comes_down);
|
||||
RUN_TEST(test_tetris_four_rows_at_once_score_most);
|
||||
RUN_TEST(test_tetris_ten_lines_are_a_level_and_a_faster_fall);
|
||||
RUN_TEST(test_tetris_the_fall_never_gets_faster_than_its_limit);
|
||||
RUN_TEST(test_tetris_is_over_when_a_piece_has_no_room);
|
||||
RUN_TEST(test_tetris_the_same_seed_deals_the_same_pieces);
|
||||
RUN_TEST(test_tetris_every_piece_in_every_turn_has_four_cells_in_its_box);
|
||||
RUN_TEST(test_a_tune_gives_its_notes_in_time);
|
||||
RUN_TEST(test_a_tune_keeps_time_over_uneven_steps);
|
||||
RUN_TEST(test_a_tune_skips_what_a_long_gap_missed);
|
||||
RUN_TEST(test_korobeiniki_is_whole_bars);
|
||||
RUN_TEST(test_the_tune_hurries_with_the_level);
|
||||
RUN_TEST(test_a_new_pace_starts_at_the_next_note);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <string>
|
||||
|
||||
#include "http_head.h"
|
||||
#include "resume.h"
|
||||
|
||||
using namespace roro::release;
|
||||
|
||||
@@ -42,6 +43,92 @@ void test_a_download_head() {
|
||||
TEST_ASSERT_EQUAL_STRING("https://elsewhere.example/x", r.head().location.c_str());
|
||||
}
|
||||
|
||||
void test_a_part_of_a_file() {
|
||||
std::string head = "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 1000-2510383/2510384\r\n"
|
||||
"ETag: \"ac948b3f\"\r\nContent-Length: 2509384\r\n\r\n";
|
||||
HttpHeadParser p;
|
||||
p.feed(head.data(), head.size());
|
||||
TEST_ASSERT_EQUAL_INT(206, p.head().status);
|
||||
TEST_ASSERT_EQUAL_INT(1000, p.head().rangeFrom);
|
||||
TEST_ASSERT_EQUAL_INT(2510383, p.head().rangeTo);
|
||||
TEST_ASSERT_EQUAL_INT(2510384, p.head().rangeTotal);
|
||||
TEST_ASSERT_EQUAL_STRING("\"ac948b3f\"", p.head().etag.c_str());
|
||||
// A range that makes no sense is no range.
|
||||
for (const char* bad : {"bytes */2510384", "bytes 5-2/10", "bytes 0-10/10", "items 0-1/2", ""}) {
|
||||
std::string h = std::string("HTTP/1.1 206 Partial Content\r\nContent-Range: ") + bad + "\r\n\r\n";
|
||||
HttpHeadParser q;
|
||||
q.feed(h.data(), h.size());
|
||||
TEST_ASSERT_EQUAL_INT(-1, q.head().rangeFrom);
|
||||
}
|
||||
}
|
||||
|
||||
static HttpHead part(long from, long total, const char* etag = "\"abc\"") {
|
||||
HttpHead h;
|
||||
h.status = 206;
|
||||
h.rangeFrom = from;
|
||||
h.rangeTo = total - 1;
|
||||
h.rangeTotal = total;
|
||||
h.contentLength = total - from;
|
||||
h.etag = etag;
|
||||
return h;
|
||||
}
|
||||
|
||||
void test_the_rest_of_the_same_file_is_taken() {
|
||||
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "\"abc\"").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000, ""), 1000, 5000, "\"abc\"").c_str()); // no ETag this time
|
||||
TEST_ASSERT_EQUAL_STRING("", resumeRefusal(part(1000, 5000), 1000, 5000, "").c_str()); // none the first time
|
||||
}
|
||||
|
||||
void test_anything_else_is_refused() {
|
||||
HttpHead whole;
|
||||
whole.status = 200; // If-Range did not match: the file is another one now
|
||||
whole.contentLength = 5000;
|
||||
TEST_ASSERT_EQUAL_STRING("The file changed on the server", resumeRefusal(whole, 1000, 5000, "\"abc\"").c_str());
|
||||
HttpHead gone;
|
||||
gone.status = 404;
|
||||
TEST_ASSERT_EQUAL_STRING("The server answered 404", resumeRefusal(gone, 1000, 5000, "").c_str());
|
||||
TEST_ASSERT_FALSE(resumeRefusal(part(0, 5000), 1000, 5000, "\"abc\"").empty()); // from the start
|
||||
TEST_ASSERT_FALSE(resumeRefusal(part(1000, 6000), 1000, 5000, "\"abc\"").empty()); // another size
|
||||
TEST_ASSERT_FALSE(resumeRefusal(part(1000, 5000, "\"xyz\""), 1000, 5000, "\"abc\"").empty());
|
||||
HttpHead shortPart = part(1000, 5000);
|
||||
shortPart.rangeTo = 2000; // not up to the end
|
||||
TEST_ASSERT_FALSE(resumeRefusal(shortPart, 1000, 5000, "\"abc\"").empty());
|
||||
HttpHead chunked = part(1000, 5000);
|
||||
chunked.chunked = true;
|
||||
TEST_ASSERT_FALSE(resumeRefusal(chunked, 1000, 5000, "\"abc\"").empty());
|
||||
}
|
||||
|
||||
void test_only_a_strong_etag_goes_in_if_range() {
|
||||
TEST_ASSERT_EQUAL_STRING("\"abc\"", ifRangeFor("\"abc\"").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", ifRangeFor("W/\"abc\"").c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("", ifRangeFor("").c_str());
|
||||
}
|
||||
|
||||
void test_how_long_a_download_is_tried() {
|
||||
ResumePolicy poor; // a poor link: each try brings some more
|
||||
for (int i = 0; i < ResumePolicy::kMaxResumes; i++) TEST_ASSERT_TRUE(poor.again(1000 * (i + 1)));
|
||||
TEST_ASSERT_FALSE(poor.again(1000000));
|
||||
TEST_ASSERT_EQUAL_INT(ResumePolicy::kMaxResumes, poor.resumes());
|
||||
|
||||
ResumePolicy dead; // the link is gone: nothing more comes
|
||||
TEST_ASSERT_TRUE(dead.again(5000));
|
||||
TEST_ASSERT_TRUE(dead.again(5000));
|
||||
TEST_ASSERT_TRUE(dead.again(5000));
|
||||
TEST_ASSERT_FALSE(dead.again(5000));
|
||||
|
||||
ResumePolicy fromNothing; // not even a first byte
|
||||
TEST_ASSERT_TRUE(fromNothing.again(0));
|
||||
TEST_ASSERT_TRUE(fromNothing.again(0));
|
||||
TEST_ASSERT_FALSE(fromNothing.again(0));
|
||||
|
||||
ResumePolicy recovering; // stuck twice, then bytes again: the count starts over
|
||||
recovering.again(5000);
|
||||
recovering.again(5000);
|
||||
recovering.again(5000);
|
||||
TEST_ASSERT_TRUE(recovering.again(9000));
|
||||
TEST_ASSERT_TRUE(recovering.again(9000));
|
||||
}
|
||||
|
||||
void test_a_head_that_is_not_http() {
|
||||
HttpHeadParser p;
|
||||
std::string junk = "\x16\x03\x01 not http at all\r\n\r\n";
|
||||
@@ -114,6 +201,11 @@ int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_a_head_in_pieces);
|
||||
RUN_TEST(test_a_download_head);
|
||||
RUN_TEST(test_a_part_of_a_file);
|
||||
RUN_TEST(test_the_rest_of_the_same_file_is_taken);
|
||||
RUN_TEST(test_anything_else_is_refused);
|
||||
RUN_TEST(test_only_a_strong_etag_goes_in_if_range);
|
||||
RUN_TEST(test_how_long_a_download_is_tried);
|
||||
RUN_TEST(test_a_head_that_is_not_http);
|
||||
RUN_TEST(test_chunked_bodies);
|
||||
RUN_TEST(test_urls);
|
||||
|
||||
@@ -301,6 +301,56 @@ void test_question_mark_is_help_only_when_not_typing() {
|
||||
TEST_ASSERT_EQUAL('?', ev[0].ch);
|
||||
}
|
||||
|
||||
void test_held_keys_are_known_between_events() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(false);
|
||||
TEST_ASSERT_FALSE(m.held(Key::Up));
|
||||
RawKeys keys;
|
||||
keys.chars = {';'};
|
||||
TEST_ASSERT_EQUAL(1, m.update(keys).size()); // the press, once
|
||||
TEST_ASSERT_TRUE(m.held(Key::Up));
|
||||
TEST_ASSERT_FALSE(m.held(Key::Down));
|
||||
TEST_ASSERT_EQUAL(0, m.update(keys).size()); // still held: no new event, still held
|
||||
TEST_ASSERT_TRUE(m.held(Key::Up));
|
||||
keys.chars = {';', '/'}; // a second key with it
|
||||
m.update(keys);
|
||||
TEST_ASSERT_TRUE(m.held(Key::Up));
|
||||
TEST_ASSERT_TRUE(m.held(Key::Right));
|
||||
keys.chars = {'/'};
|
||||
m.update(keys);
|
||||
TEST_ASSERT_FALSE(m.held(Key::Up));
|
||||
TEST_ASSERT_TRUE(m.held(Key::Right));
|
||||
m.update(RawKeys());
|
||||
TEST_ASSERT_FALSE(m.held(Key::Right));
|
||||
}
|
||||
|
||||
void test_held_arrows_need_fn_while_typing() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(true);
|
||||
RawKeys keys;
|
||||
keys.chars = {'.'};
|
||||
m.update(keys);
|
||||
TEST_ASSERT_FALSE(m.held(Key::Down)); // it types a full stop
|
||||
TEST_ASSERT_TRUE(m.heldChar('.'));
|
||||
keys.fn = true;
|
||||
m.update(keys);
|
||||
TEST_ASSERT_TRUE(m.held(Key::Down));
|
||||
}
|
||||
|
||||
void test_held_characters_and_enter() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(false);
|
||||
RawKeys keys;
|
||||
keys.chars = {'z', ' '};
|
||||
keys.enter = true;
|
||||
m.update(keys);
|
||||
TEST_ASSERT_TRUE(m.heldChar('z'));
|
||||
TEST_ASSERT_TRUE(m.heldChar(' '));
|
||||
TEST_ASSERT_FALSE(m.heldChar('x'));
|
||||
TEST_ASSERT_TRUE(m.held(Key::Select));
|
||||
TEST_ASSERT_FALSE(m.held(Key::Back)); // not asked for by anything: only what a Game steers with
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_plain_character);
|
||||
@@ -327,5 +377,8 @@ int main() {
|
||||
RUN_TEST(test_fn_p_is_a_screenshot_in_both_modes);
|
||||
RUN_TEST(test_plain_h_still_types);
|
||||
RUN_TEST(test_question_mark_is_help_only_when_not_typing);
|
||||
RUN_TEST(test_held_keys_are_known_between_events);
|
||||
RUN_TEST(test_held_arrows_need_fn_while_typing);
|
||||
RUN_TEST(test_held_characters_and_enter);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ void test_every_setting_is_in_one_group() {
|
||||
total += f.menu.count();
|
||||
f.menu.close();
|
||||
}
|
||||
TEST_ASSERT_EQUAL(21, total);
|
||||
TEST_ASSERT_EQUAL(22, total);
|
||||
int i = -1;
|
||||
TEST_ASSERT_TRUE(f.menu.reveal(SettingsMenu::Row::Vpn, i));
|
||||
TEST_ASSERT_EQUAL_STRING("Network", f.menu.title().c_str());
|
||||
@@ -204,6 +204,16 @@ void test_pause_gnss_for_lora_is_off_by_default() {
|
||||
|
||||
// R1, Q165: the device looks for a newer release once a day. It installs nothing by itself, so it
|
||||
// is on unless switched off.
|
||||
void test_installing_updates_by_itself_is_off_by_default() {
|
||||
Fixture f;
|
||||
int row = f.row(SettingsMenu::Row::InstallUpdates);
|
||||
TEST_ASSERT_EQUAL_STRING("Install updates", f.menu.label(row).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("When asked", f.menu.value(row).c_str());
|
||||
f.menu.toggle(row);
|
||||
TEST_ASSERT_TRUE(f.settings.getBool(Setting::InstallUpdates));
|
||||
TEST_ASSERT_EQUAL_STRING("By itself", f.menu.value(row).c_str());
|
||||
}
|
||||
|
||||
void test_check_for_updates_is_on_by_default() {
|
||||
Fixture f;
|
||||
int row = f.row(SettingsMenu::Row::CheckUpdates);
|
||||
@@ -245,6 +255,7 @@ int main() {
|
||||
RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
|
||||
RUN_TEST(test_pause_gnss_for_lora_is_off_by_default);
|
||||
RUN_TEST(test_check_for_updates_is_on_by_default);
|
||||
RUN_TEST(test_installing_updates_by_itself_is_off_by_default);
|
||||
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
|
||||
RUN_TEST(test_the_debug_console_is_a_page_that_says_off);
|
||||
return UNITY_END();
|
||||
|
||||