@@ -56,6 +56,8 @@ // Style picks the default theme and the modem effect speed. Style *StyleConfig `yaml:"style,omitempty"`
Tools *ToolsConfig `yaml:"tools,omitempty"`
+ // Games runs NES games through the nesdoor door (see games.go).
+ Games *GamesConfig `yaml:"games,omitempty"`
// Version is the binary version, injected by main; not a config key.
Version string `yaml:"-"`
@@ -158,6 +160,9 @@ if _, err := time.ParseDuration(v); err != nil { return fmt.Errorf("bbs.jobs.%s %q is not a valid duration: %w", name, v, err)
}
}
+ }
+ if err := c.Games.validate(); err != nil {
+ return err
}
if c.Files != nil && (c.Files.QuotaBytes < 0 || c.Files.MaxFileInput < 0) {
return fmt.Errorf("bbs.files sizes must be >= 0")
@@ -0,0 +1,180 @@+package bbs
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+// NES games come from nesdoor (https://github.com/AlexCorris/nesdoor), a BBS
+// door that draws the picture in sixel graphics. It is a separate GPL-2.0
+// program run as a child process, never linked into Hammer. The menu offers
+// games only when the door is configured and found on the host, the same way
+// lychee, DuckDB and MTR appear only when installed.
+
+const (
+ defaultGamePlayers = 3
+ defaultGameMinutes = 25
+ defaultDoorTempDir = "cache/doors"
+ // doorGrace is how long a door gets to save and exit after the caller
+ // hangs up, before it is killed.
+ doorGrace = 5 * time.Second
+)
+
+// GamesConfig is the optional "bbs.games" block.
+type GamesConfig struct {
+ Enabled bool `yaml:"enabled"`
+ // DoorDir is the nesdoor folder: nesdoor.py, the libretro core, and the
+ // saves/ and sessions/ folders the door writes.
+ DoorDir string `yaml:"doorDir"`
+ // Python runs the door. Absent = DoorDir/venv/bin/python, else python3.
+ Python string `yaml:"python"`
+ // RomsDir holds the .nes files. Absent = DoorDir/roms.
+ RomsDir string `yaml:"romsDir"`
+ // MaxPlayers is how many people may play at once. Absent or 0 = 3.
+ MaxPlayers int `yaml:"maxPlayers"`
+ // MaxMinutes caps one sitting; the door saves and exits on time. The SSH
+ // connection's own limit caps it too. Absent or 0 = 25.
+ MaxMinutes int `yaml:"maxMinutes"`
+ // AllowAnonymous lets keyless sessions play (without saves that last).
+ AllowAnonymous bool `yaml:"allowAnonymous"`
+}
+
+func (g *GamesConfig) validate() error {
+ if g == nil {
+ return nil
+ }
+ if g.MaxPlayers < 0 || g.MaxMinutes < 0 {
+ return fmt.Errorf("bbs.games.maxPlayers and maxMinutes must be >= 0")
+ }
+ if g.Enabled && g.DoorDir == "" {
+ return fmt.Errorf("bbs.games.doorDir is required when games are enabled")
+ }
+
+ return nil
+}
+
+// gamesSettings is a resolved, runnable games setup.
+type gamesSettings struct {
+ program string // the interpreter (or, in tests, the door itself)
+ args []string // arguments that start the door
+ env []string // extra environment for the door (tests)
+ dir string // the door's working directory
+ tempDir string // where per-run drop files go
+ maxPlayers int
+ maxTime time.Duration
+ anonymous bool
+}
+
+// resolveGames checks the host for what the config names. It returns nil,
+// with the reason logged, when games cannot run here.
+func resolveGames(g *GamesConfig) *gamesSettings {
+ if g == nil || !g.Enabled {
+ return nil
+ }
+ off := func(why string, args ...any) *gamesSettings {
+ log.Printf("bbs: games disabled: "+why, args...)
+
+ return nil
+ }
+
+ if runtime.GOOS == "windows" {
+ // nesdoor's stdio mode waits on its pipes with select(), which
+ // Windows only offers for sockets.
+ return off("the door's stdio mode needs a Unix host")
+ }
+
+ dir, err := filepath.Abs(g.DoorDir)
+ if err != nil {
+ return off("doorDir %q: %v", g.DoorDir, err)
+ }
+ script := filepath.Join(dir, "nesdoor.py")
+ if _, err := os.Stat(script); err != nil {
+ return off("%s not found", script)
+ }
+
+ python := findPython(g.Python, dir)
+ if python == "" {
+ return off("no Python interpreter found (set bbs.games.python)")
+ }
+
+ roms := g.RomsDir
+ if roms == "" {
+ roms = filepath.Join(dir, "roms")
+ }
+ if roms, err = filepath.Abs(roms); err != nil {
+ return off("romsDir %q: %v", g.RomsDir, err)
+ }
+ if n := countROMs(roms); n == 0 {
+ log.Printf("bbs: games: no .nes files in %s yet", roms)
+ } else {
+ log.Printf("bbs: games: %d games in %s", n, roms)
+ }
+
+ players := g.MaxPlayers
+ if players <= 0 {
+ players = defaultGamePlayers
+ }
+ minutes := g.MaxMinutes
+ if minutes <= 0 {
+ minutes = defaultGameMinutes
+ }
+
+ return &gamesSettings{
+ program: python,
+ args: []string{script, "--roms", roms, "--io", "stdio"},
+ dir: dir,
+ tempDir: defaultDoorTempDir,
+ maxPlayers: players,
+ maxTime: time.Duration(minutes) * time.Minute,
+ anonymous: g.AllowAnonymous,
+ }
+}
+
+// findPython resolves the configured interpreter, else the door's own venv,
+// else python3 on the PATH.
+func findPython(configured, doorDir string) string {
+ if configured != "" {
+ if _, err := os.Stat(configured); err == nil {
+ return configured
+ }
+ if p, err := exec.LookPath(configured); err == nil {
+ return p
+ }
+
+ return ""
+ }
+
+ venv := filepath.Join(doorDir, "venv", "bin", "python")
+ if _, err := os.Stat(venv); err == nil {
+ return venv
+ }
+ for _, name := range []string{"python3", "python"} {
+ if p, err := exec.LookPath(name); err == nil {
+ return p
+ }
+ }
+
+ return ""
+}
+
+// countROMs is how many .nes files the door will list.
+func countROMs(dir string) int {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return 0
+ }
+ n := 0
+ for _, e := range entries {
+ if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".nes") {
+ n++
+ }
+ }
+
+ return n
+}
@@ -0,0 +1,224 @@+package bbs
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/runawaydevil/hammer/internal/community"
+)
+
+// The test binary doubles as a fake door: with HAMMER_FAKE_DOOR set it
+// behaves like nesdoor would, instead of running the tests.
+func TestMain(m *testing.M) {
+ if os.Getenv("HAMMER_FAKE_DOOR") != "" {
+ fakeGameDoor()
+ os.Exit(0)
+ }
+ os.Exit(m.Run())
+}
+
+// fakeGameDoor greets the caller by alias, echoes keys in upper case, names
+// Ctrl+C, leaves on "q", and on hang-up leaves a "saved" file behind.
+func fakeGameDoor() {
+ data, err := os.ReadFile(os.Getenv("BBSDEV_DRP"))
+ if err != nil {
+ fmt.Print("NO DROP FILE\r\n")
+
+ return
+ }
+ lines := strings.Split(string(data), "\n")
+ fmt.Printf("DOOR HELLO %s\r\n", lines[3])
+
+ r := bufio.NewReader(os.Stdin)
+ for {
+ b, err := r.ReadByte()
+ if err != nil {
+ _ = os.WriteFile("saved", []byte(lines[4]), 0o600)
+
+ return
+ }
+ switch b {
+ case 'q':
+ fmt.Print("DOOR BYE\r\n")
+
+ return
+ case 0x03:
+ fmt.Print("CTRL-C")
+ default:
+ fmt.Print(strings.ToUpper(string(b)))
+ }
+ }
+}
+
+func fakeGames(t *testing.T, players int) *gamesSettings {
+ t.Helper()
+
+ return &gamesSettings{
+ program: os.Args[0],
+ dir: t.TempDir(),
+ tempDir: t.TempDir(),
+ env: []string{"HAMMER_FAKE_DOOR=1"},
+ maxPlayers: players,
+ maxTime: 20 * time.Minute,
+ }
+}
+
+func whereIs(srv *Server, nick string) string {
+ for _, o := range srv.presence.List() {
+ if o.Nick == nick {
+ return o.Where
+ }
+ }
+
+ return ""
+}
+
+func TestGamesHiddenWithoutTheDoor(t *testing.T) {
+ srv := testServer(t, &Config{})
+ addr := listen(t, srv)
+
+ ana := login(t, addr, "ana", newSigner(t))
+ ana.never("NES in sixel")
+ ana.send("g")
+ ana.waitFor("unknown key")
+}
+
+func TestGamesMisconfiguredStaysHidden(t *testing.T) {
+ srv := testServer(t, &Config{Games: &GamesConfig{Enabled: true, DoorDir: t.TempDir()}})
+ if srv.games != nil {
+ t.Fatal("games on without nesdoor.py")
+ }
+}
+
+func TestGamesRunTheDoor(t *testing.T) {
+ srv := testServer(t, &Config{})
+ srv.setGames(fakeGames(t, 2))
+ addr := listen(t, srv)
+
+ ana := login(t, addr, "ana", newSigner(t))
+ ana.waitFor("NES in sixel")
+ ana.send("g")
+ ana.waitFor("GAMES")
+ ana.send("\r")
+ ana.waitFor("DOOR HELLO ana")
+
+ if w := whereIs(srv, "ana"); w != community.WhereGame {
+ t.Fatalf("ana is %q, want %q", w, community.WhereGame)
+ }
+ if srv.gameSlots.InUse() != 1 {
+ t.Fatalf("slots in use = %d", srv.gameSlots.InUse())
+ }
+
+ // Keys reach the door raw: Ctrl+C belongs to the game, not the BBS.
+ ana.send("x\x03y")
+ ana.waitFor("XCTRL-CY")
+
+ mark := ana.mark()
+ ana.send("q")
+ ana.waitFor("DOOR BYE")
+ ana.waitSince(mark, "HAMMER")
+
+ if w := whereIs(srv, "ana"); w != community.WhereMenu {
+ t.Fatalf("after the game ana is %q", w)
+ }
+ if srv.gameSlots.InUse() != 0 {
+ t.Fatalf("slot not released: %d in use", srv.gameSlots.InUse())
+ }
+
+ // The BBS still works after the door.
+ ana.send("a")
+ ana.waitFor("press any key")
+}
+
+func TestGamesBackOutWithoutPlaying(t *testing.T) {
+ srv := testServer(t, &Config{})
+ srv.setGames(fakeGames(t, 1))
+ addr := listen(t, srv)
+
+ ana := login(t, addr, "ana", newSigner(t))
+ ana.send("g")
+ ana.waitFor("GAMES")
+ mark := ana.mark()
+ ana.send("b")
+ ana.waitSince(mark, "NES in sixel")
+ ana.never("DOOR HELLO")
+}
+
+func TestGamesNeedAKey(t *testing.T) {
+ srv := testServer(t, &Config{AllowAnonymous: true})
+ srv.setGames(fakeGames(t, 1))
+ addr := listen(t, srv)
+
+ guest := login(t, addr, "guest", nil)
+ guest.send("g")
+ guest.waitFor("Games need an SSH key")
+ guest.never("DOOR HELLO")
+}
+
+func TestGamesFull(t *testing.T) {
+ srv := testServer(t, &Config{})
+ srv.setGames(fakeGames(t, 1))
+ addr := listen(t, srv)
+
+ ana := login(t, addr, "ana", newSigner(t))
+ ana.send("g")
+ ana.waitFor("GAMES")
+ ana.send("\r")
+ ana.waitFor("DOOR HELLO ana")
+
+ bia := login(t, addr, "bia", newSigner(t))
+ bia.send("g")
+ bia.waitFor("GAMES")
+ bia.send("\r")
+ bia.waitFor("All 1 game slots are taken")
+ bia.never("DOOR HELLO")
+
+ // Leave cleanly, so the door is gone before the test's folders are.
+ ana.send("q")
+ ana.waitFor("DOOR BYE")
+ waitNoGames(t, srv)
+}
+
+// waitNoGames waits until every door has exited and freed its node.
+func waitNoGames(t *testing.T, srv *Server) {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for srv.gameSlots.InUse() > 0 {
+ if time.Now().After(deadline) {
+ t.Fatalf("%d games still running", srv.gameSlots.InUse())
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+}
+
+func TestGamesHangUpLetsTheDoorSave(t *testing.T) {
+ srv := testServer(t, &Config{})
+ g := fakeGames(t, 1)
+ srv.setGames(g)
+ addr := listen(t, srv)
+
+ signer := newSigner(t)
+ client := dial(t, addr, signer)
+ tm := openTerm(t, client, "")
+ tm.send("\r")
+ tm.waitFor("HAMMER")
+ tm.send("g")
+ tm.waitFor("GAMES")
+ tm.send("\r")
+ tm.waitFor("DOOR HELLO")
+
+ client.Close() // the line drops mid-game
+
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ if _, err := os.Stat(g.dir + "/saved"); err == nil && srv.gameSlots.InUse() == 0 {
+ return
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatalf("door did not save on hang-up, or the slot was kept (%d in use)", srv.gameSlots.InUse())
+}
@@ -201,6 +201,19 @@ "BBS_UNKNOWN_WALL": "unknown option — try p, o or b", "BBS_BANNED": "You are banned from posting",
"BBS_WHERE_MENU": "menu",
"BBS_WHERE_CHAT": "in the chat",
+ "BBS_GAMES": "games",
+ "BBS_GAMES_SUM": "NES in sixel",
+ "BBS_TITLE_GAMES": "GAMES",
+ "BBS_GAMES_INTRO1": "Homebrew NES games, drawn in sixel graphics.",
+ "BBS_GAMES_INTRO2": "Needs a sixel terminal: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
+ "BBS_GAMES_INTRO3": "Sound and held keys work in SyncTERM and IcyTerm.",
+ "BBS_GAMES_INTRO4": "Up to %d minutes a sitting; leaving saves your game.",
+ "BBS_GAMES_PLAY": "play",
+ "BBS_GAMES_ANON": "Games need an SSH key — connect with one to play",
+ "BBS_GAMES_FULL": "All %d game slots are taken — try again soon",
+ "BBS_GAMES_LATE": "This connection is about to time out — reconnect to play",
+ "BBS_GAMES_FAILED": "The games could not start — try again later",
+ "BBS_WHERE_GAME": "playing",
"BBS_ANON_TAG": "anonymous",
"BBS_CONNS": "%d connections",
"BBS_CHAT_TITLE": "CHAT · #lobby",
@@ -58,6 +58,11 @@ } lines = append(lines,
st.menuEntry("w", st.t("BBS_WALL"), st.t("BBS_WALL_SUM")),
st.menuEntry("c", st.t("BBS_CHAT"), st.t("BBS_CHAT_SUM")),
+ )
+ if st.gamesOn() {
+ lines = append(lines, st.menuEntry("g", st.t("BBS_GAMES"), st.t("BBS_GAMES_SUM")))
+ }
+ lines = append(lines,
st.menuEntry("s", st.t("BBS_SETTINGS"), ""),
st.menuEntry("a", st.t("BBS_ABOUT"), ""),
st.menuEntry("m", st.t("BBS_MORE_TOOLS"), st.t("BBS_MORE_TOOLS_SUM")),
@@ -18,6 +18,7 @@ "github.com/runawaydevil/hammer/internal/community"
"github.com/runawaydevil/hammer/internal/dispatch"
"github.com/runawaydevil/hammer/internal/domain"
+ "github.com/runawaydevil/hammer/internal/door"
"github.com/runawaydevil/hammer/internal/jobs"
"github.com/runawaydevil/hammer/internal/localization"
"github.com/runawaydevil/hammer/internal/ratelimit"
@@ -57,6 +58,9 @@ communityCfg communitySettings presence *community.Presence
wall *community.Wall
chat *community.Hub
+
+ games *gamesSettings // nil when games are off or the door is missing
+ gameSlots *door.Slots // nodes, one per person playing
}
func New(cfg *Config, ws *weather.WeatherService, ip ipDataLocator) (*Server, error) {
@@ -114,6 +118,8 @@ } cc := s.communityCfg
s.wall = community.NewWall(cst, cc.wallMaxLen, cc.wallCooldown)
s.chat = community.NewHub(cst, cc.chatMaxLen, cc.chatPerMinute, cc.chatBurst)
+
+ s.setGames(resolveGames(cfg.Games))
catalog, err := dispatch.NewCatalog(s.dispatchTools()...)
if err != nil {
@@ -177,6 +177,12 @@ case 'w', 'W': st.wallMenu()
case 'c', 'C':
st.chatRoom()
+ case 'g', 'G':
+ if st.gamesOn() {
+ st.gamesMenu()
+ } else {
+ st.note = st.tf("BBS_UNKNOWN_MAIN", st.mainNumericRange())
+ }
case 'q', 'Q':
st.farewell()
@@ -226,5 +226,18 @@ "BBS_THEME": "theme", "BBS_THEME_RETRO90": "retro 90s",
"BBS_THEME_MODERN": "retro-modern",
"BBS_THEME_PLAIN": "plain",
- "BBS_FX": "modem effect"
+ "BBS_FX": "modem effect",
+ "BBS_GAMES": "games",
+ "BBS_GAMES_SUM": "NES in sixel",
+ "BBS_TITLE_GAMES": "GAMES",
+ "BBS_GAMES_INTRO1": "Homebrew NES games, drawn in sixel graphics.",
+ "BBS_GAMES_INTRO2": "Needs a sixel terminal: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
+ "BBS_GAMES_INTRO3": "Sound and held keys work in SyncTERM and IcyTerm.",
+ "BBS_GAMES_INTRO4": "Up to %d minutes a sitting; leaving saves your game.",
+ "BBS_GAMES_PLAY": "play",
+ "BBS_GAMES_ANON": "Games need an SSH key — connect with one to play",
+ "BBS_GAMES_FULL": "All %d game slots are taken — try again soon",
+ "BBS_GAMES_LATE": "This connection is about to time out — reconnect to play",
+ "BBS_GAMES_FAILED": "The games could not start — try again later",
+ "BBS_WHERE_GAME": "playing"
}
@@ -226,5 +226,18 @@ "BBS_THEME": "tema", "BBS_THEME_RETRO90": "retrô anos 90",
"BBS_THEME_MODERN": "retrô-moderno",
"BBS_THEME_PLAIN": "simples",
- "BBS_FX": "efeito de modem"
+ "BBS_FX": "efeito de modem",
+ "BBS_GAMES": "jogos",
+ "BBS_GAMES_SUM": "NES em sixel",
+ "BBS_TITLE_GAMES": "JOGOS",
+ "BBS_GAMES_INTRO1": "Jogos homebrew de NES, desenhados em sixel.",
+ "BBS_GAMES_INTRO2": "Precisa de terminal com sixel: SyncTERM, IcyTerm, WezTerm, foot, iTerm2.",
+ "BBS_GAMES_INTRO3": "Som e teclas seguradas funcionam no SyncTERM e no IcyTerm.",
+ "BBS_GAMES_INTRO4": "Até %d minutos por partida; ao sair, o jogo é salvo.",
+ "BBS_GAMES_PLAY": "jogar",
+ "BBS_GAMES_ANON": "Os jogos precisam de uma chave SSH — conecte com uma para jogar",
+ "BBS_GAMES_FULL": "As %d vagas de jogo estão ocupadas — tente daqui a pouco",
+ "BBS_GAMES_LATE": "Esta conexão está perto do limite de tempo — reconecte para jogar",
+ "BBS_GAMES_FAILED": "Os jogos não puderam iniciar — tente mais tarde",
+ "BBS_WHERE_GAME": "jogando"
}