refact round 2

This commit is contained in:
Zsolt Tasnadi
2026-03-11 07:28:14 +01:00
parent e837a9a04e
commit d843df816a
23 changed files with 834 additions and 659 deletions

40
engine/i18n.go Normal file
View File

@@ -0,0 +1,40 @@
package engine
// T is the i18n dictionary type
type T map[string]string
// En is the default English dictionary
var En = T{
"AskName": "Enter your name:",
"Greeting": "Hello, %s%s%s! Welcome to Teletype BBS!",
"Goodbye": "Goodbye, %s! 👋",
"Pause": "Press ENTER...",
"Choice": "Choice: ",
"MsgBoardTitle": "📋 MESSAGE BOARD",
"MsgNoMessages": "(No messages yet — be the first!)",
"MsgNew": "Write new message",
"MsgBack": "Back",
"MsgEnterText": "Message text:",
"MsgSent": "✓ Sent!",
"MsgEmpty": "(Empty not sent)",
"WikiLoading": "Loading...",
"WikiConnError": "Connection error",
"WikiNoResults": "No results for tag '%s'.",
"WikiEnterNum": "Enter number to open, ENTER to go back:",
"WikiFetchContent": "Fetching content...",
"CatTitle": "🎮 GAME CATALOG",
"CatNoGames": "No games available.",
"CatLatest": "Latest",
"CatVersions": "%d versions available",
"CatFull": "Full catalog",
"OnlineTitle": "👥 ONLINE USERS",
"OnlineYou": "← you",
"OnlineTotal": "Total: %s%d%s users online",
"SysInfoTitle": " SYSTEM INFO",
"SysServerTime": "Server time",
"SysOnlineUsers": "Online users",
"SysMsgCount": "Message count",
"SysWikiURL": "Wiki URL",
"SysGamesAPI": "Games API",
"SysPlatform": "Platform",
}

78
engine/menu.go Normal file
View File

@@ -0,0 +1,78 @@
package engine
import (
"fmt"
"strings"
)
// HandlerFunc is the handler type for a menu item
type HandlerFunc func(*Session)
// Exit is a built-in handler that terminates the session
var Exit HandlerFunc = func(s *Session) { s.Quit = true }
type menuItem struct {
Key string
Label string
Color string
Handler HandlerFunc
}
// Menu defines the BBS main menu using a declarative DSL
type Menu struct {
title string
items []menuItem
}
// Title sets the menu header
func (m *Menu) Title(t string) {
m.title = t
}
// Item adds a menu entry (key, display label, ANSI color, handler)
func (m *Menu) Item(key, label, color string, handler HandlerFunc) {
m.items = append(m.items, menuItem{
Key: strings.ToUpper(key),
Label: label,
Color: color,
Handler: handler,
})
}
// Dispatch finds and calls the handler for the given key
func (m *Menu) Dispatch(s *Session, key string) {
key = strings.ToUpper(strings.TrimSpace(key))
for _, item := range m.items {
if item.Key == key {
item.Handler(s)
return
}
}
}
// Render returns the rendered menu string
func (m *Menu) Render(p *Printer, lang T, username string) string {
headerLine := strings.Repeat("═", W)
l1 := p.PadLine(fmt.Sprintf(" %s%s%s %s@%s%s", YL, m.title, R, GY, username, R), W)
var rows []string
for i := 0; i < len(m.items); i += 2 {
if i+1 < len(m.items) {
left := p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i].Color, m.items[i].Key, R, m.items[i].Label), W/2)
right := p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i+1].Color, m.items[i+1].Key, R, m.items[i+1].Label), W/2)
rows = append(rows, left+right)
} else {
rows = append(rows, p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i].Color, m.items[i].Key, R, m.items[i].Label), W))
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n%s╔%s╗%s\n", WH, headerLine, R))
sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", WH, l1, WH, R))
sb.WriteString(fmt.Sprintf("%s╠%s╣%s\n", WH, headerLine, R))
for _, row := range rows {
sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", WH, row, WH, R))
}
sb.WriteString(fmt.Sprintf("%s╚%s╝%s\n%s", WH, headerLine, R, lang["Choice"]))
return sb.String()
}

197
engine/printer.go Normal file
View File

@@ -0,0 +1,197 @@
package engine
import (
"bytes"
"fmt"
"net"
"regexp"
"strings"
"time"
"unicode/utf8"
)
// ANSI color codes
const (
R = "\033[0m"
B = "\033[1m"
DIM = "\033[2m"
CY = "\033[1;36m"
YL = "\033[1;33m"
GR = "\033[1;32m"
RD = "\033[1;31m"
MG = "\033[1;35m"
WH = "\033[1;37m"
BL = "\033[1;34m"
GY = "\033[0;37m"
)
// W is the default terminal width
const W = 70
type Printer struct {
Conn net.Conn
}
func NewPrinter(conn net.Conn) *Printer {
return &Printer{Conn: conn}
}
func (p *Printer) Send(text string) {
normalized := strings.ReplaceAll(text, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\n", "\r\n")
p.Conn.Write([]byte(normalized))
}
func (p *Printer) ReadLine() (string, error) {
var buf bytes.Buffer
for {
b := make([]byte, 1)
_, err := p.Conn.Read(b)
if err != nil {
return "", err
}
ch := b[0]
if ch == 255 {
cmd := make([]byte, 2)
_, err := p.Conn.Read(cmd)
if err != nil {
return "", err
}
if cmd[0] == 250 {
for {
tmp := make([]byte, 1)
_, err := p.Conn.Read(tmp)
if err != nil || tmp[0] == 240 {
break
}
}
}
continue
}
if ch == '\r' || ch == '\n' {
res := buf.String()
p.Send("\r\n")
return res, nil
}
if ch == 8 || ch == 127 {
if buf.Len() > 0 {
curr := buf.Bytes()
buf.Reset()
buf.Write(curr[:len(curr)-1])
p.Send("\b \b")
}
continue
}
if ch >= 32 && ch < 127 {
buf.WriteByte(ch)
p.Send(string(ch))
}
}
}
func (p *Printer) Pause(lang T) {
p.Send(fmt.Sprintf("\r\n%s [ %s ]%s ", GY, lang["Pause"], R))
p.ReadLine()
}
func (p *Printer) VisibleLen(s string) int {
re := regexp.MustCompile(`\033\[[0-9;]*m`)
return utf8.RuneCountInString(re.ReplaceAllString(s, ""))
}
func (p *Printer) PadLine(content string, width int) string {
vLen := p.VisibleLen(content)
padding := width - vLen
if padding < 0 {
padding = 0
}
return content + strings.Repeat(" ", padding)
}
func (p *Printer) BoxHeader(title string, color string) {
line := strings.Repeat("═", W)
titleLen := utf8.RuneCountInString(title)
padding := (W - 2 - titleLen) / 2
if padding < 0 {
padding = 0
}
inner := strings.Repeat(" ", padding) + title + strings.Repeat(" ", W-2-titleLen-padding)
p.Send(fmt.Sprintf(
"\n%s%s%s\n%s║%s %s%s%s %s║%s\n%s%s%s\n",
color, line, R,
color, R, B, inner, R, color, R,
color, line, R,
))
}
func (p *Printer) HR(char string, color string) {
p.Send(fmt.Sprintf("%s%s%s", color, strings.Repeat(char, W), R))
}
func (p *Printer) FmtDate(s string) string {
t, err := time.Parse(time.RFC3339, strings.Replace(s, "Z", "+00:00", 1))
if err != nil {
if len(s) >= 10 {
return s[:10]
}
return ""
}
return t.Format("2006-01-02")
}
func (p *Printer) StripMD(text string) string {
reImg := regexp.MustCompile(`!\[.*?\]\(.*?\)|\[(.*?)\]\(.*?\)|#{1,6}\s*|[*_` + "`" + `~>|]`)
text = reImg.ReplaceAllStringFunc(text, func(s string) string {
if strings.HasPrefix(s, "[") {
sub := regexp.MustCompile(`\[(.*?)\]\(.*?\)`)
matches := sub.FindStringSubmatch(s)
if len(matches) > 1 {
return matches[1]
}
}
if strings.HasPrefix(s, "!") || strings.HasPrefix(s, "#") || strings.ContainsAny(s, "*_`~>|") {
return ""
}
return s
})
return strings.TrimSpace(text)
}
func (p *Printer) Wrapped(text string, indent int, maxChars int) string {
if len(text) > maxChars {
text = text[:maxChars]
}
text = p.StripMD(text)
prefix := strings.Repeat(" ", indent)
var lines []string
paras := strings.Split(text, "\n")
for _, para := range paras {
para = strings.TrimSpace(para)
if para == "" {
lines = append(lines, "")
continue
}
words := strings.Fields(para)
if len(words) == 0 {
continue
}
currentLine := prefix + words[0]
for _, word := range words[1:] {
if len(currentLine)+1+len(word) > W {
lines = append(lines, currentLine)
currentLine = prefix + word
} else {
currentLine += " " + word
}
}
lines = append(lines, currentLine)
}
return strings.Join(lines, "\r\n")
}

123
engine/server.go Normal file
View File

@@ -0,0 +1,123 @@
package engine
import (
"fmt"
"net"
"os"
"strings"
)
// Config holds the BBS server configuration
type Config struct {
Host string
Port string
Banner string
Lang T
}
// BBS is the main server struct
type BBS struct {
config Config
state *State
menu *Menu
}
// New creates a new BBS server with the given configuration
func New(cfg Config) *BBS {
return &BBS{
config: cfg,
state: newState(),
menu: &Menu{},
}
}
// State returns the shared server state (for use by content handlers)
func (b *BBS) State() *State {
return b.state
}
// Menu configures the main menu using the DSL
func (b *BBS) Menu(fn func(*Menu)) {
fn(b.menu)
}
// Start launches the TCP server and blocks waiting for connections
func (b *BBS) Start() {
ln, err := net.Listen("tcp", b.config.Host+":"+b.config.Port)
if err != nil {
fmt.Printf("Server start error: %v\n", err)
os.Exit(1)
}
defer ln.Close()
fmt.Printf("BBS running → telnet localhost %s\n", b.config.Port)
fmt.Println("Stop: Ctrl+C")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Printf("Accept error: %v\n", err)
continue
}
fmt.Printf("[+] Connected: %s\n", conn.RemoteAddr().String())
go b.handleClient(conn)
}
}
func (b *BBS) handleClient(conn net.Conn) {
defer conn.Close()
addr := conn.RemoteAddr().String()
lang := b.config.Lang
printer := NewPrinter(conn)
// Telnet negotiation (IAC WILL ECHO, IAC WILL SGA, IAC WONT LINEMODE)
printer.Send("\xff\xfb\x01\xff\xfb\x03\xff\xfe\x22")
printer.Send(CY + b.config.Banner + R)
printer.Send(fmt.Sprintf("\n%s%s%s ", WH, lang["AskName"], R))
username, err := printer.ReadLine()
if err != nil {
return
}
username = strings.TrimSpace(username)
if username == "" {
username = "Anonymous"
}
if len(username) > 20 {
username = username[:20]
}
b.state.Mu.Lock()
b.state.OnlineUsers[addr] = username
b.state.Mu.Unlock()
defer func() {
b.state.Mu.Lock()
delete(b.state.OnlineUsers, addr)
b.state.Mu.Unlock()
}()
printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", GR, fmt.Sprintf(lang["Greeting"], WH, username, GR), R))
session := &Session{
State: b.state,
Printer: printer,
Username: username,
Addr: addr,
Lang: lang,
}
for !session.Quit {
printer.Send(b.menu.Render(printer, lang, username))
choice, err := printer.ReadLine()
if err != nil {
break
}
b.menu.Dispatch(session, choice)
}
if session.Quit {
printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n\r\n", RD, fmt.Sprintf(lang["Goodbye"], username), R))
}
}

43
engine/session.go Normal file
View File

@@ -0,0 +1,43 @@
package engine
import "sync"
// State holds shared server state (thread-safe)
type State struct {
OnlineUsers map[string]string // addr -> username
Mu sync.Mutex
}
func newState() *State {
return &State{
OnlineUsers: make(map[string]string),
}
}
// UserCount returns the number of online users in a thread-safe manner
func (st *State) UserCount() int {
st.Mu.Lock()
defer st.Mu.Unlock()
return len(st.OnlineUsers)
}
// Snapshot returns a thread-safe copy of the online users map
func (st *State) Snapshot() map[string]string {
st.Mu.Lock()
defer st.Mu.Unlock()
snap := make(map[string]string)
for k, v := range st.OnlineUsers {
snap[k] = v
}
return snap
}
// Session represents an active user connection
type Session struct {
State *State
Printer *Printer
Username string
Addr string
Lang T
Quit bool
}