feat(bbs): themes per session, ANSI art on screen, modem effect
Every interactive session now picks its theme from the profile (or
bbs.style.defaultTheme, retro90 by default) at the colour level its
terminal reports, and the art shows up:
- splash: the Hammer piece at modem speed (bbs.style.baud, 9600 by
default), credited from its SAUCE record; any key finishes the drawing
- wall, chat, weather, tools and settings: header art above the menu when
the terminal is wide and tall enough
- goodbye: NO CARRIER, then "see you"
- narrower than 80 columns, or no colour: text titles, no art
Input is now read by a goroutine per session and handed to nextByte over
a channel, so a screen can check for a key without blocking; this is what
lets a keypress cut the modem effect short.
A status bar pins "nick · N online · theme · connected for" to the last
row of menu screens. Settings gains theme and modem-effect entries,
saved in the profile. Dumb terminals get the plain theme: ASCII boxes, no
colour codes and no cursor movement.
README gains a Look and feel section; deploy configs gain bbs.style;
.gitattributes marks .ans files as binary.
author
Pablo Murad
date
24 Sep 2026, 15:44 -0300
commit
85ce72d126769b42de6d7513d391f3cbb04ade70
parent
98a0bf5b3c2a07aaf871fd30dd0ecf5edda4215e
changes
22 files +657 −33
@@ -0,0 +1,2 @@ +# ANSI art is CP437 bytes with CRLF line ends and a binary SAUCE record.
+*.ans binary
@@ -32,6 +32,7 @@ | NDJSON dispatcher for scripts and agents | working | | Per-key file area over SFTP | working |
| Background (async) jobs | working, in memory |
| Community: nicknames, wall, who's online, live chat | working |
+| Themes (retro 90s, retro-modern) and original ANSI art | working |
The direction is described under [Roadmap](#roadmap). Anything not listed as working
above does not exist yet, and this file will not pretend otherwise.
@@ -117,6 +118,30 @@ (`d 3` on the wall screen). A ban covers both the chat and the wall and survives restarts.
Scripts and agents get the same data through the dispatcher: `who`, `wall count=10` and
`wall.post text="..."` (keys only, same cooldown). The chat is interactive only.
+
+### Look and feel
+
+Two themes, picked per person under settings:
+
+- **retro 90s** (the default): the 16 VGA colours, double-line boxes with block shadows,
+ dotted menu leaders, the look of a 1990s art pack.
+- **retro-modern**: the same BBS layout with rounded boxes, a softer palette and gradient
+ titles on truecolor terminals.
+
+Hammer reads what your terminal can do from `TERM` and `COLORTERM` and draws at that
+level: 24-bit colour, 256 colours, 16 colours, or none at all. A terminal with no colour
+(`TERM=dumb`) gets plain ASCII boxes and no escape sequences.
+
+The splash, the wall, the chat, the weather, the tools, settings and the goodbye screen
+carry ANSI art drawn for Hammer, stored as ordinary `.ans` files (CP437, iCE colours,
+SAUCE) that open in PabloDraw or Moebius. The splash arrives at modem speed,
+`bbs.style.baud` (9600 by default); any key finishes it at once, and settings can turn
+the effect off. Art needs 80 columns: narrower terminals get text titles instead.
+
+Art lives in `internal/bbs/art/` and is regenerated with `go generate ./internal/bbs`
+from `tools/artgen`. Any `.ans` file dropped there is loaded the same way, with the
+credit read from its SAUCE record. Art by someone else needs the author's permission:
+archives such as 16colo.rs keep the art, but the rights stay with the artists.
### Scripts and agents (NDJSON)
@@ -571,6 +596,9 @@ workers: 4 maxPerKey: 5
ttl: 20m
timeout: 2m
+ style:
+ defaultTheme: retro90 # or modern
+ baud: 9600 # modem effect on the splash; 0 = instant
community:
admins: ["SHA256:..."] # moderators' key fingerprints
wallShow: 20
@@ -73,6 +73,9 @@ maxFileInput: 8388608 # largest file a tool reads via file=NAME dispatch:
requestsPerMinute: 60 # tool calls per key (per IP when anonymous)
burst: 20
+ style:
+ defaultTheme: retro90 # retro90 (16 colours, double lines) or modern
+ baud: 9600 # modem effect on the splash; 0 = instant
community:
admins: [] # moderators: SSH key fingerprints, e.g. "SHA256:AbC..."
wallShow: 20 # one-liners shown on the wall
@@ -70,6 +70,9 @@ maxFileInput: 8388608 # largest file a tool reads via file=NAME dispatch:
requestsPerMinute: 60 # tool calls per key (per IP when anonymous)
burst: 20
+ style:
+ defaultTheme: retro90 # retro90 (16 colours, double lines) or modern
+ baud: 9600 # modem effect on the splash; 0 = instant
community:
admins: [] # moderators: SSH key fingerprints, e.g. "SHA256:AbC..."
wallShow: 20 # one-liners shown on the wall
@@ -22,6 +22,15 @@ // message it prints. Only the session goroutine reads input.
const eraseLine = "\r\033[2K"
+// erase clears the input row; a dumb terminal gets it overwritten with spaces.
+func (c *chatView) erase() string {
+ if c.st.level == ui.NoColor {
+ return "\r" + strings.Repeat(" ", max(c.st.width0()-1, 0)) + "\r"
+ }
+
+ return eraseLine
+}
+
type chatView struct {
st *session
prompt string
@@ -35,7 +44,7 @@ func (c *chatView) print(line string) { c.st.wmu.Lock()
defer c.st.wmu.Unlock()
- c.st.writeLocked(eraseLine + line + "\r\n" + c.inputLocked())
+ c.st.writeLocked(c.erase() + line + "\r\n" + c.inputLocked())
}
// redraw repaints the input line.
@@ -43,7 +52,7 @@ func (c *chatView) redraw() { c.st.wmu.Lock()
defer c.st.wmu.Unlock()
- c.st.writeLocked(eraseLine + c.inputLocked())
+ c.st.writeLocked(c.erase() + c.inputLocked())
}
// inputLocked is the prompt plus as much of the typed text as fits on one
@@ -125,7 +134,7 @@ c.st.wmu.Lock() defer c.st.wmu.Unlock()
change()
- c.st.writeLocked(eraseLine + c.inputLocked())
+ c.st.writeLocked(c.erase() + c.inputLocked())
}
// chatRoom runs the chat screen until the user leaves.
@@ -162,6 +171,7 @@ } view := &chatView{st: st, prompt: prompt, max: st.srv.communityCfg.chatMaxLen}
st.clear()
+ st.drawArt("chat", false, false)
st.write([]string{
st.th.Heading("⚡ "+st.t("BBS_CHAT_TITLE")) + st.th.Dim + " · " + st.th.Reset +
st.th.Good + "● " + st.tf("BBS_ONLINE", st.srv.presence.Count()) + st.th.Reset,
@@ -52,6 +52,8 @@ // Jobs bounds background (async) tool calls. Jobs *JobsConfig `yaml:"jobs,omitempty"`
// Community configures the wall, the chat and their moderators.
Community *CommunityConfig `yaml:"community,omitempty"`
+ // Style picks the default theme and the modem effect speed.
+ Style *StyleConfig `yaml:"style,omitempty"`
Tools *ToolsConfig `yaml:"tools,omitempty"`
@@ -142,6 +144,9 @@ if c == nil { return nil
}
if err := c.Community.validate(); err != nil {
+ return err
+ }
+ if err := c.Style.validate(); err != nil {
return err
}
if c.Jobs != nil {
@@ -81,7 +81,7 @@ "BBS_UNITS": "units", "BBS_LANGUAGE": "language",
"BBS_VIEW": "view",
"BBS_EMOJI": "emoji",
- "BBS_UNKNOWN_SET": "unknown key — try 1-4 or b",
+ "BBS_UNKNOWN_SET": "unknown key — try 1-7 or b",
"BBS_CURRENT": " current ",
"BBS_LANG_CODE": "language code: ",
"BBS_UNKNOWN_LANG": "unknown language %s — try en, pt, de, es, fr...",
@@ -227,4 +227,9 @@ "BBS_CHAT_HELP_NICK": "/nick name change your nickname", "BBS_CHAT_HELP_WHO": "/who who is in the room",
"BBS_CHAT_HELP_QUIT": "/quit back to the menu (Ctrl+C too)",
"BBS_CHAT_HELP_MOD": "/mute nick [10m] · /kick nick · /ban nick [reason] · /unban nick",
+ "BBS_THEME": "theme",
+ "BBS_THEME_RETRO90": "retro 90s",
+ "BBS_THEME_MODERN": "retro-modern",
+ "BBS_THEME_PLAIN": "plain",
+ "BBS_FX": "modem effect",
}
@@ -80,8 +80,8 @@ func (st *session) about() { st.clear()
w := st.width0()
- if art, err := artLines("hammer.ans"); err == nil {
- st.write(ui.CenterBlock(art, w))
+ if !st.drawArt("splash", false, true) {
+ st.write([]string{"", ui.CenterBlock([]string{st.th.Heading("⚡ HAMMER")}, w)[0]})
}
st.write([]string{""})
@@ -51,6 +51,8 @@ catalog *dispatch.Catalog jobs *jobs.Manager
calls *ratelimit.Limiter // tool calls per identity (dispatcher)
+ arts *artLibrary
+
communityCfg communitySettings
presence *community.Presence
wall *community.Wall
@@ -102,6 +104,7 @@ jobs: jobs.New(cfg.jobsConfig()), calls: ratelimit.New(cfg.DispatchLimits()),
}
+ s.arts = loadArtLibrary()
s.communityCfg = cfg.community()
s.presence = community.NewPresence()
cst, err := community.NewStore(store.SQL())
@@ -67,6 +67,10 @@ } if cfg.Files == nil {
cfg.Files = &FilesConfig{Dir: filepath.Join(dir, "files")}
}
+ if cfg.Style == nil {
+ instant := 0
+ cfg.Style = &StyleConfig{Baud: &instant} // no modem effect unless a test asks
+ }
if cfg.Tools == nil {
cfg.Tools = &ToolsConfig{LycheePath: "/no/lychee", DuckDBPath: "/no/duckdb", MtrPath: "/no/mtr", PingPath: "/no/ping"}
}
@@ -45,6 +45,9 @@ inbuf []byte err error
th ui.Theme // colours, boxes and art for this terminal
+ level ui.ColorLevel // what the terminal can show
+ in chan inChunk // input, read by startReader
+ since time.Time // when the session started
pres *community.Conn // this connection in "who is online"
chatSub *community.Sub // set while the chat screen is open
}
@@ -80,7 +83,8 @@ return
}
- st := &session{srv: s, sess: sess, width: 80, height: 24, fp: identity, anon: isAnonymous(identity), th: ui.Modern(ui.Color256)}
+ st := &session{srv: s, sess: sess, width: 80, height: 24, fp: identity, anon: isAnonymous(identity), since: time.Now()}
+ st.level = ui.DetectCaps(ptyReq.Term, sess.Environ())
if ptyReq.Window.Width > 0 {
st.width = ptyReq.Window.Width
}
@@ -96,12 +100,17 @@ return
}
st.profile = profile
+ st.applyTheme()
if st.anon {
st.note = st.t("BBS_ANON_NOTE")
}
st.pres = s.presence.Join(identity, profile.Nick, st.anon)
defer st.pres.Leave()
+
+ done := make(chan struct{})
+ defer close(done)
+ st.startReader(done)
go func() {
for win := range winCh {
@@ -182,25 +191,44 @@ }
func (st *session) farewell() {
st.clear()
- st.write([]string{"", ""})
+ if !st.drawArt("nocarrier", true, false) {
+ st.write([]string{"", ""})
+ }
+ st.write([]string{""})
st.write(ui.CenterBlock([]string{st.th.Heading(st.t("BBS_SEE_YOU"))}, st.width0()))
st.write([]string{""})
}
+// splash greets the caller: the Hammer art at modem speed when the terminal
+// is wide enough, otherwise the name and the flashing tagline.
func (st *session) splash() {
st.clear()
w := st.width0()
- if art, err := artLines("hammer.ans"); err == nil {
- st.write(ui.CenterBlock(art, w))
+ if st.drawArt("splash", true, true) {
+ st.write(ui.CenterBlock([]string{st.th.Accent + attributionAuthor + st.th.Reset}, w))
+ st.write([]string{""})
+ st.pause("")
+
+ return
}
+ st.write([]string{"", ui.CenterBlock([]string{st.th.Heading("⚡ HAMMER")}, w)[0], ""})
st.write(ui.CenterBlock([]string{st.th.Accent + attributionAuthor + st.th.Reset}, w))
-
st.write([]string{""})
st.flashTagline(w)
st.write([]string{""})
st.pause("")
+}
+
+// applyTheme picks the session's theme: the profile's choice, else the
+// server default, at what the terminal can show.
+func (st *session) applyTheme() {
+ name := st.profile.Theme
+ if name == "" {
+ name = st.srv.cfg.defaultTheme()
+ }
+ st.th = ui.ThemeByName(name, st.level)
}
func (st *session) flashTagline(w int) {
@@ -92,6 +92,12 @@ } }
func (st *session) clear() {
+ if st.th.Name == "plain" && st.level == ui.NoColor {
+ // A dumb terminal cannot clear; leave some room instead.
+ st.writeString("\r\n\r\n")
+
+ return
+ }
st.writeString(ui.ClearScreen)
}
@@ -118,6 +124,8 @@ if st.note != "" { st.write([]string{st.pad() + st.th.Dim + st.note + st.th.Reset})
st.note = ""
}
+
+ st.statusBar(len(block) + 2)
}
func (st *session) pad() string {
@@ -133,6 +141,11 @@ if !st.alive() { return 0, false
}
+ if len(st.inbuf) == 0 && st.in != nil {
+ if !st.readChunk() {
+ return 0, false
+ }
+ }
if len(st.inbuf) == 0 {
if st.sess == nil {
st.setErr(io.EOF)
@@ -0,0 +1,344 @@ +package bbs
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+
+ "github.com/runawaydevil/hammer/internal/ansiart"
+ "github.com/runawaydevil/hammer/internal/ui"
+)
+
+// StyleConfig is the optional "bbs.style" block.
+type StyleConfig struct {
+ // DefaultTheme is what sessions start with: retro90 (default) or modern.
+ DefaultTheme string `yaml:"defaultTheme"`
+ // Baud is the speed of the modem effect on the splash and the goodbye
+ // screen. Absent = 9600; an explicit 0 draws art instantly.
+ Baud *int `yaml:"baud"`
+}
+
+const defaultBaud = 9600
+
+func (c *Config) defaultTheme() string {
+ if c == nil || c.Style == nil || c.Style.DefaultTheme == "" {
+ return "retro90"
+ }
+
+ return c.Style.DefaultTheme
+}
+
+func (c *Config) baud() int {
+ if c == nil || c.Style == nil || c.Style.Baud == nil {
+ return defaultBaud
+ }
+
+ return max(*c.Style.Baud, 0)
+}
+
+func (c *StyleConfig) validate() error {
+ if c == nil || c.DefaultTheme == "" {
+ return nil
+ }
+ for _, n := range ui.ThemeNames {
+ if c.DefaultTheme == n {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("bbs.style.defaultTheme %q: use one of %s", c.DefaultTheme, strings.Join(ui.ThemeNames, ", "))
+}
+
+// ----------------------------------------------------------------------------
+// Art
+
+// artLibrary holds the decoded .ans files and their renderings per theme.
+type artLibrary struct {
+ pieces map[string]*ansiart.Art
+ mu sync.Mutex
+ rendered map[string][]string
+}
+
+func loadArtLibrary() *artLibrary {
+ lib := &artLibrary{pieces: map[string]*ansiart.Art{}, rendered: map[string][]string{}}
+ entries, err := fs.ReadDir(artFS, "art")
+ if err != nil {
+ log.Println("bbs: art:", err)
+
+ return lib
+ }
+ for _, e := range entries {
+ name, ok := strings.CutSuffix(e.Name(), ".ans")
+ if !ok {
+ continue
+ }
+ data, err := artFS.ReadFile("art/" + e.Name())
+ if err != nil {
+ continue
+ }
+ art, err := ansiart.Load(data)
+ if err != nil {
+ log.Printf("bbs: art %s: %v", e.Name(), err)
+
+ continue
+ }
+ lib.pieces[name] = art
+ }
+
+ return lib
+}
+
+// render returns a piece in a theme, cached.
+func (l *artLibrary) render(name string, th ui.Theme) ([]string, *ansiart.Art) {
+ art := l.pieces[name]
+ if art == nil {
+ return nil, nil
+ }
+ key := fmt.Sprintf("%s|%s|%d", name, th.Name, th.Level)
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ if lines, ok := l.rendered[key]; ok {
+ return lines, art
+ }
+ lines := art.Render(th.Palette, th.Level)
+ l.rendered[key] = lines
+
+ return lines, art
+}
+
+// artLines is a piece ready for this session, or nil when the theme shows no
+// art or the terminal is too narrow for it.
+func (st *session) artLines(name string) ([]string, *ansiart.Art) {
+ if st.srv == nil || st.srv.arts == nil || !st.th.Art {
+ return nil, nil
+ }
+ lines, art := st.srv.arts.render(name, st.th)
+ if art == nil || st.width0() < art.Width {
+ return nil, nil
+ }
+
+ return lines, art
+}
+
+// drawArt shows a piece centred, optionally at modem speed, with its credit
+// line when asked (always for art that is not Hammer's own). It reports
+// whether anything was drawn.
+func (st *session) drawArt(name string, modem, credit bool) bool {
+ lines, art := st.artLines(name)
+ if lines == nil {
+ return false
+ }
+ block := ui.CenterBlock(lines, st.width0())
+ if modem && st.effects() {
+ st.typeOut(block)
+ } else {
+ st.write(block)
+ }
+ c := art.Credit()
+ if c != "" && (credit || !strings.HasPrefix(c, "art: hammer")) {
+ st.write(ui.CenterBlock([]string{st.th.Dim + c + st.th.Reset}, st.width0()))
+ }
+
+ return true
+}
+
+// screenWithArt draws a header piece above a menu box when both fit on the
+// terminal, then the box.
+func (st *session) screenWithArt(name string, box []string) {
+ if lines, _ := st.artLines(name); lines != nil && st.height0() >= len(lines)+len(box)+4 {
+ st.drawArt(name, false, false)
+ }
+ st.screen(box)
+}
+
+// ----------------------------------------------------------------------------
+// Modem effect
+
+// effects reports whether this session draws art at modem speed.
+func (st *session) effects() bool {
+ return st.srv != nil && st.srv.cfg.baud() > 0 && st.profile != nil && st.profile.FX != "off"
+}
+
+// typeOut writes lines at the configured baud rate (10 bits a character),
+// a few characters at a time, the way art arrived over a 90s modem. Any key
+// finishes the drawing at once; the key stays queued for whatever comes next.
+func (st *session) typeOut(lines []string) {
+ cps := st.srv.cfg.baud() / 10
+ const burst = 8
+ perBurst := time.Duration(burst) * time.Second / time.Duration(max(cps, 1))
+
+ for i, line := range lines {
+ visible := 0
+ var chunk strings.Builder
+ for _, tok := range ansiTokens(line) {
+ chunk.WriteString(tok)
+ if tok[0] == 0x1b {
+ continue
+ }
+ visible++
+ if visible%burst == 0 {
+ st.writeString(chunk.String())
+ chunk.Reset()
+ time.Sleep(perBurst)
+ if st.keyWaiting() || !st.alive() {
+ st.writeString(line[len(prefixOf(line, visible)):] + "\r\n")
+ st.write(lines[i+1:])
+
+ return
+ }
+ }
+ }
+ st.writeString(chunk.String() + "\r\n")
+ }
+}
+
+// ansiTokens splits s into escape sequences and single characters.
+func ansiTokens(s string) []string {
+ var out []string
+ for i := 0; i < len(s); {
+ if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' {
+ j := i + 2
+ for j < len(s) && (s[j] < 0x40 || s[j] > 0x7e) {
+ j++
+ }
+ j = min(j+1, len(s))
+ out = append(out, s[i:j])
+ i = j
+
+ continue
+ }
+ _, size := utf8.DecodeRuneInString(s[i:])
+ out = append(out, s[i:i+size])
+ i += size
+ }
+
+ return out
+}
+
+// prefixOf returns the part of s holding its first n visible characters,
+// escape sequences included.
+func prefixOf(s string, n int) string {
+ seen, end := 0, 0
+ for _, tok := range ansiTokens(s) {
+ if seen == n {
+ break
+ }
+ end += len(tok)
+ if tok[0] != 0x1b {
+ seen++
+ }
+ }
+
+ return s[:end]
+}
+
+// ----------------------------------------------------------------------------
+// Input
+
+type inChunk struct {
+ b []byte
+ err error
+}
+
+// startReader moves reading the SSH channel to its own goroutine, so a
+// screen can check for a key without blocking (keyWaiting). Everything
+// still reads through nextByte, which takes from the channel.
+func (st *session) startReader(done <-chan struct{}) {
+ in := make(chan inChunk, 8)
+ st.in = in
+ go func() {
+ for {
+ buf := make([]byte, 256)
+ n, err := st.sess.Read(buf)
+ if n > 0 {
+ select {
+ case in <- inChunk{b: buf[:n]}:
+ case <-done:
+ return
+ }
+ }
+ if err != nil {
+ select {
+ case in <- inChunk{err: err}:
+ case <-done:
+ }
+
+ return
+ }
+ }
+ }()
+}
+
+// readChunk blocks for the next input and appends it to inbuf.
+func (st *session) readChunk() bool {
+ ch, ok := <-st.in
+ if !ok || ch.err != nil {
+ err := error(io.EOF)
+ if ch.err != nil {
+ err = ch.err
+ }
+ st.setErr(err)
+
+ return false
+ }
+ st.inbuf = append(st.inbuf, ch.b...)
+
+ return true
+}
+
+// keyWaiting reports, without blocking, whether input is ready.
+func (st *session) keyWaiting() bool {
+ if len(st.inbuf) > 0 {
+ return true
+ }
+ if st.in == nil {
+ return false
+ }
+ select {
+ case ch, ok := <-st.in:
+ if !ok || ch.err != nil {
+ st.setErr(io.EOF)
+
+ return true
+ }
+ st.inbuf = append(st.inbuf, ch.b...)
+
+ return true
+ default:
+ return false
+ }
+}
+
+// ----------------------------------------------------------------------------
+// Status bar
+
+// statusBar pins "nick · N online · theme · connected for" to the last row
+// of the terminal, when the screen leaves room for it.
+func (st *session) statusBar(used int) {
+ h := st.height0()
+ if st.pres == nil || st.profile == nil || st.th.Level == ui.NoColor || h < used+4 {
+ return
+ }
+ text := fmt.Sprintf(" %s · %s · %s · %s ", st.profile.Nick,
+ st.tf("BBS_ONLINE", st.srv.presence.Count()), st.themeLabel(st.th.Name), ago(time.Since(st.since)))
+ line := st.th.Frame + "▐" + st.th.Reset + st.th.Accent + text + st.th.Reset + st.th.Frame + "▌" + st.th.Reset
+ st.writeString(fmt.Sprintf("\0337\033[%d;1H\033[2K%s\0338", h, ui.CenterBlock([]string{line}, st.width0())[0]))
+}
+
+// themeLabel names a theme in the session's language.
+func (st *session) themeLabel(name string) string {
+ switch name {
+ case "modern":
+ return st.t("BBS_THEME_MODERN")
+ case "plain":
+ return st.t("BBS_THEME_PLAIN")
+ }
+
+ return st.t("BBS_THEME_RETRO90")
+}
@@ -0,0 +1,130 @@ +package bbs
+
+import (
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ gossh "golang.org/x/crypto/ssh"
+)
+
+var sgr = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+// dialAs connects with signer (or keyless) as user.
+func dialAs(t *testing.T, addr, user string, signer gossh.Signer) *gossh.Client {
+ t.Helper()
+ auth := []gossh.AuthMethod{gossh.KeyboardInteractive(func(_, _ string, q []string, _ []bool) ([]string, error) {
+ return make([]string, len(q)), nil
+ })}
+ if signer != nil {
+ auth = []gossh.AuthMethod{gossh.PublicKeys(signer)}
+ }
+ client, err := gossh.Dial("tcp", addr, &gossh.ClientConfig{
+ User: user, Auth: auth, HostKeyCallback: gossh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second,
+ })
+ if err != nil {
+ t.Fatalf("dial: %v", err)
+ }
+ t.Cleanup(func() { client.Close() })
+
+ return client
+}
+
+func TestSplashArtAndCredit(t *testing.T) {
+ srv := testServer(t, &Config{})
+ tm := openTermAs(t, dialAs(t, listen(t, srv), "ana", newSigner(t)), "", "xterm-256color", 120)
+
+ tm.waitFor("art: hammer / hammer bbs, 2026")
+ tm.waitFor(attributionAuthor)
+ tm.send("\r")
+ tm.waitFor("╔") // the retro theme's double-line box
+ tm.send("q")
+ tm.waitFor("see you")
+ tm.waitFor("CD") // the modem panel under NO CARRIER
+}
+
+func TestThemeChoicePersists(t *testing.T) {
+ srv := testServer(t, &Config{})
+ addr := listen(t, srv)
+ key := newSigner(t)
+
+ tm := login(t, addr, "ana", key)
+ tm.waitFor("╔")
+ m := tm.mark()
+ tm.send("s")
+ tm.waitSince(m, "SETTINGS")
+ m = tm.mark()
+ tm.send("6") // retro90 -> modern
+ tm.waitSince(m, "╭")
+
+ again := login(t, addr, "ana", key)
+ again.waitFor("╭")
+ if strings.Contains(again.out.String(), "╔") {
+ t.Fatal("the modern theme was not kept for the next connection")
+ }
+}
+
+func TestDumbTerminalGetsNoColour(t *testing.T) {
+ srv := testServer(t, &Config{})
+ tm := openTermAs(t, dialAs(t, listen(t, srv), "ana", newSigner(t)), "", "dumb", 80)
+ tm.send("\r")
+ tm.waitFor("hammer> ")
+ tm.send("w")
+ tm.waitFor("WALL")
+
+ if codes := sgr.FindAllString(tm.out.String(), -1); len(codes) > 0 {
+ t.Fatalf("a dumb terminal received colour codes: %q", codes[:min(len(codes), 5)])
+ }
+ if !strings.Contains(tm.out.String(), "+-[") {
+ t.Fatal("a dumb terminal should get ASCII boxes")
+ }
+}
+
+func TestNarrowTerminalSkipsArt(t *testing.T) {
+ srv := testServer(t, &Config{})
+ tm := openTermAs(t, dialAs(t, listen(t, srv), "ana", newSigner(t)), "", "xterm", 60)
+ tm.waitFor("HAMMER")
+ tm.send("\r")
+ tm.waitFor("hammer> ")
+ if strings.Contains(tm.out.String(), "art: hammer") {
+ t.Fatal("80-column art was drawn on a 60-column terminal")
+ }
+}
+
+func TestModemEffectSkipsOnKey(t *testing.T) {
+ slow := 300 // 30 characters a second: the splash would take about a minute
+ srv := testServer(t, &Config{Style: &StyleConfig{Baud: &slow}})
+ tm := openTermAs(t, dialAs(t, listen(t, srv), "ana", newSigner(t)), "", "xterm", 120)
+
+ start := time.Now()
+ time.Sleep(200 * time.Millisecond)
+ tm.send("\r") // finishes the drawing; the same key then passes the splash
+ tm.waitFor("hammer> ")
+ if d := time.Since(start); d > 4*time.Second {
+ t.Fatalf("a key did not skip the modem effect (took %s)", d)
+ }
+ tm.waitFor("art: hammer") // the art was completed, not dropped
+}
+
+func TestEffectsCanBeTurnedOff(t *testing.T) {
+ slow := 300
+ srv := testServer(t, &Config{Style: &StyleConfig{Baud: &slow}})
+ addr := listen(t, srv)
+ key := newSigner(t)
+
+ // Turn the effect off in settings (the first login skips it with a key).
+ tm := login(t, addr, "ana", key)
+ tm.send("s")
+ tm.waitFor("SETTINGS")
+ m := tm.mark()
+ tm.send("7")
+ tm.waitSince(m, "SETTINGS")
+
+ start := time.Now()
+ again := openTermAs(t, dialAs(t, addr, "ana", key), "", "xterm", 120)
+ again.waitFor(attributionAuthor)
+ if d := time.Since(start); d > 3*time.Second {
+ t.Fatalf("with effects off the splash should be instant (took %s)", d)
+ }
+}
@@ -2,8 +2,6 @@ package bbs
import (
"embed"
-
- "github.com/runawaydevil/hammer/internal/ui"
)
// The .ans files in art/ are drawn by tools/artgen; regenerate them with
@@ -20,13 +18,3 @@ attributionAuthor = "developed by pablo murad 2026" tagline = "tools over SSH"
inviteURL = "Visit https://pablomurad.com"
)
-
-// artLines reads an embedded ANSI art block by file name.
-func artLines(name string) ([]string, error) {
- data, err := artFS.ReadFile("art/" + name)
- if err != nil {
- return nil, err
- }
-
- return ui.SplitLines(data), nil
-}
@@ -79,7 +79,7 @@ "BBS_UNITS": "units", "BBS_LANGUAGE": "language",
"BBS_VIEW": "view",
"BBS_EMOJI": "emoji",
- "BBS_UNKNOWN_SET": "unknown key — try 1-4 or b",
+ "BBS_UNKNOWN_SET": "unknown key — try 1-7 or b",
"BBS_CURRENT": " current ",
"BBS_LANG_CODE": "language code: ",
"BBS_UNKNOWN_LANG": "unknown language %s — try en, pt, de, es, fr...",
@@ -224,5 +224,10 @@ "BBS_CHAT_HELP_ME": "/me action describe what you are doing", "BBS_CHAT_HELP_NICK": "/nick name change your nickname",
"BBS_CHAT_HELP_WHO": "/who who is in the room",
"BBS_CHAT_HELP_QUIT": "/quit back to the menu (Ctrl+C too)",
- "BBS_CHAT_HELP_MOD": "/mute nick [10m] · /kick nick · /ban nick [reason] · /unban nick"
+ "BBS_CHAT_HELP_MOD": "/mute nick [10m] · /kick nick · /ban nick [reason] · /unban nick",
+ "BBS_THEME": "theme",
+ "BBS_THEME_RETRO90": "retro 90s",
+ "BBS_THEME_MODERN": "retro-modern",
+ "BBS_THEME_PLAIN": "plain",
+ "BBS_FX": "modem effect"
}
@@ -79,7 +79,7 @@ "BBS_UNITS": "unidades", "BBS_LANGUAGE": "idioma",
"BBS_VIEW": "visão",
"BBS_EMOJI": "emoji",
- "BBS_UNKNOWN_SET": "tecla desconhecida — tente 1-4 ou b",
+ "BBS_UNKNOWN_SET": "tecla desconhecida — tente 1-7 ou b",
"BBS_CURRENT": " atual ",
"BBS_LANG_CODE": "código do idioma: ",
"BBS_UNKNOWN_LANG": "idioma desconhecido %s — tente en, pt, de, es, fr...",
@@ -224,5 +224,10 @@ "BBS_CHAT_HELP_ME": "/me ação descreve o que você está fazendo", "BBS_CHAT_HELP_NICK": "/nick nome troca seu apelido",
"BBS_CHAT_HELP_WHO": "/who quem está na sala",
"BBS_CHAT_HELP_QUIT": "/quit volta ao menu (Ctrl+C também)",
- "BBS_CHAT_HELP_MOD": "/mute apelido [10m] · /kick apelido · /ban apelido [motivo] · /unban apelido"
+ "BBS_CHAT_HELP_MOD": "/mute apelido [10m] · /kick apelido · /ban apelido [motivo] · /unban apelido",
+ "BBS_THEME": "tema",
+ "BBS_THEME_RETRO90": "retrô anos 90",
+ "BBS_THEME_MODERN": "retrô-moderno",
+ "BBS_THEME_PLAIN": "simples",
+ "BBS_FX": "efeito de modem"
}