EN /HU | Belépés

Flow

A rubbs Flow rétege: sororientált, deklaratív párbeszéd, amivel minden board kezdődik. Ez az oldal a BBS::Flow minden lépését felsorolja szignatúrával, alapértékekkel és példával, elmagyarázza, hogyan osztoznak a lépések az adatokon (a context), mikor áll meg egy flow, hogyan működnek az ERB-screenek és a bannerek, a végén pedig egy teljes login-és-menü board és a valódi Impostor BBS jön. A rubbs 1.0.0 alapján.

Áttekintés

RUBY
require 'bbs'

DEMO = BBS::Flow.define do
  banner 'WELCOME'
  ask :name, prompt: 'Your name'
  text { |ctx| "Hello, #{ctx[:name]}!" }
end

BBS.configure { |c| c.flow = DEMO }
BBS.start

A BBS::Flow.define(screens_dir: nil) { … } egy új Flow-n értékeli ki a blokkot, és befagyasztva adja vissza. A blokk minden DSL-hívása egy lépést fűz a flow.steps végére: egy kis BBS::Flow::Steps-objektumot, aminek run(runner) metódusa van; ekkor még senkinek nem megy ki semmi. A flow sablon: betöltéskor egyszer épül fel, és minden hívó ugyanazt használja.

Amikor valaki betárcsáz, a BBS::FlowRunner fentről lefelé lefuttatja a lépéseket az ő vonalán. A kiíró lépések ANSI-szöveget írnak, a kérdező lépések egy sort olvasnak a hívótól. A szöveg görög, mint egy teletype-on; semmi nem rajzolódik újra helyben. Teljes képernyőhöz ott az Application (ablakok, menük, egér), soralapú boardhoz pedig a 40×25-ös BBS::Petscii::Screen a BBS::Selector-menüivel (lásd Commodore 64).

A legtöbb board csak ragasztónak használja a flow-t: a terminál-kérdés, esetleg egy login, aztán egy call lépés, ami a boardot futtatja, és még egy az elköszönéshez. Az alábbi lépéseken kívül nincs más; minden egyéb Ruby-kód egy call-ban.

Az oldal minden példája egy DEMO-hoz hasonló konstans; kipróbáláshoz tedd a c.flow-ba, és indítsd el a szervert, ahogy az Első lépések mutatja.

A context

Egy flow minden futásának saját contextje van: egy Symbol kulcsú Hash, a blokkokban ctx a neve. Az ask és a saját call blokkjaid írják, a blokkok olvassák, a persist mezőket ment belőle, a screen átadja az ERB-template-nek.

Kulcs Ki állítja Érték
:session_id a runner, mindig a hívó véletlen, 16 hexa számjegyű session-azonosítója
bármilyen név ask NAME, a saját call blokkjaid amit beleteszel

Az al-flow-k (menüpontok, confirm blokkok) ugyanazt a contextet használják. A BBS::FlowRunner.new(session, session_id, flow, context: {...})-val indított flow ezekkel a kulcsokkal kezd (így adja át egy door a hívó nevét); a session_id-t mindig felülírja.

Mikor áll meg egy flow

A runner sorban futtatja a lépéseket, amíg az alábbiak egyike be nem következik:

  • Elfogynak a lépések. A vezérlés visszakerül oda, ahonnan a flow-t indították; a config.flow esetén ez azt jelenti, hogy a szerver bontja a vonalat.
  • Egy lépés :halt-ot ad. Az egész flow megáll, minden befoglaló menüvel és al-flow-val együtt. :halt-ot ad: ha a hívó bármelyik olvasás közben bont, ha egy ask validálása elbukik, ha egy gate elbukik, ha egy blokk nélküli confirm-re "no" a válasz, és ha egy call blokk :halt-ot ad vissza.
  • Az exit_menu csak a legbelső menu-ből lép ki, és a flow utána folytatódik.

Hibák: egy call blokk hibája a BBS.log-on át a logba kerül (flow call: …), a hívó annyit lát, hogy "Something went wrong.", és a flow megy tovább; ha közben megszakad a vonal, a flow megáll. Bármely más lépés hibája (például egy text vagy gate blokkban, egy ismeretlen banner-font, egy hiányzó ERB-fájl) a session végét jelenti.

Lépések

Lépés Szignatúra Mit csinál
say say(text, style: :muted) egy sort ír ki üres sorok között
text text(content = nil, style: :muted) { … } egy sort ír ki, fix vagy számolt szöveggel
banner banner(text, style: :success) dupla vonalas keretes cím
big_banner big_banner(text, style: :success, font: 'slant') FIGlet-cím
screen screen(name, **vars) ERB-fájlt renderel
ask ask(name, prompt:, transform: nil, validate: nil) egy sort olvas a contextbe
pause pause(message, seconds: 1.0) kiír, és vár
confirm confirm(message, denied: nil) vagy blokkal igen/nem kérdés
gate gate(denied:) { … } megállítja a flow-t, ha a blokk hamis
menu menu(prompt, loop: false) { option … } al-flow-k számozott BBS::Selector-a
exit_menu exit_menu kilép a legbelső menüből
persist persist(*fields, store:) context-mezőket ment egy BBS::Store-ba
call call { … } tetszőleges Ruby-kód a vonallal
app app(definition) egy BBS::Application-t futtat

A set, line, section, rows, table, wait_enter, fetch, pick, body, output, os és tui lépés az 1.0 óta nincs meg: mindegyik pár sor egy call-ban (a runner line, ask és select metódusa a legtöbbet lefedi), egy BBS::FakeOS gép pedig BBS::Door-ként fut (lásd Doorok).

Stílusok

A style: a BBS::FlowRunner::STYLES egy nevét kapja; a színek az egyetlen palettából (BBS::Ansi) jönnek:

Név Szín
:success élénkzöld
:muted szürke (sima szöveg)
:error élénkpiros
:info sárga
:prompt fehér

Minden más érték (ismeretlen Symbol, nyers ANSI-string) :muted-del íródik ki. A 0.x színnevei (:confirm, :cyan, :magenta, :blue, :yellow, :white, :green, :red) megszűntek.

Kiíró lépések

say

A say(text, style: :muted) két szóközzel behúzva írja ki a text-et, előtte és utána egy üres sorral.

RUBY
DEMO = BBS::Flow.define do
  say 'Welcome to the board.'
  say 'Login successful.', style: :success
  say 'The file area is closed tonight.', style: :error
end

text

A text(content = nil, style: :muted) egy behúzott sort ír ki, üres sorok nélkül. Blokkal a blokk megkapja a contextet, és a visszatérési értéke kerül ki; nil eredménynél nem ír semmit.

RUBY
DEMO = BBS::Flow.define do
  ask :name, prompt: 'Name'
  text 'Your account:', style: :prompt
  text { |ctx| "  name:    #{ctx[:name]}" }
  text { |ctx| "  session: #{ctx[:session_id]}" }
  text(style: :info) { |ctx| 'sysop!' if ctx[:name] == 'sysop' }
end

A blokkban dobott kivételt semmi nem kapja el, és a session véget ér; a kockázatos értékeket előbb egy call-ban számold ki.

banner

A banner(text, style: :success) dupla vonalas keretbe (╔═╗ ║ ╚═╝) írja a text-et, két szóköz térközzel.

RUBY
DEMO = BBS::Flow.define do
  banner 'IDENTITY VERIFICATION', style: :info
end
TEXT
  ╔═════════════════════════╗
  ║  IDENTITY VERIFICATION  ║
  ╚═════════════════════════╝

big_banner

A big_banner(text, style: :success, font: 'slant') FIGlettel rajzolja ki a text-et az artii gemen át. Bármelyik artii-font jó (slant, standard, small, big, doom, banner, …; a bundle exec artii --list mindet kiírja). Ismeretlen fontnál kivétel jön, és a session véget ér, ezért az új fontot előbb helyben próbáld ki.

RUBY
DEMO = BBS::Flow.define do
  big_banner 'HARBOR'
  big_banner 'BBS', font: 'doom', style: :info
end

screen

A screen(name, **vars) kirendereli és kiírja a NAME.erb fájlt. Lásd lent a Screenek (ERB) részt.

RUBY
DEMO = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
  screen :welcome, year: Time.now.year, node: :session_id
end

Bekérő lépések

ask

Az ask(name, prompt:, transform: nil, validate: nil) kiírja a prompt:-ot :prompt stílussal, beolvas egy sort, levágja a szélein lévő whitespace-t, és az eredményt a ctx[name]-be teszi. Modern terminálon a karaktereket a szerver visszhangozza; PETSCII-vonalon a válasz egy BBS::Field-be kerül, ami a kurzorbillentyűkkel szerkeszthető.

Opció Értékek Hatás
prompt: String a kettőspont előtti szöveg; kötelező
transform: :upcase, :downcase, egy Proc a levágott válaszra fut a validálás előtt; a Proc a Stringet kapja, és az új értéket adja vissza
validate: :non_empty, :email, egy Proc vagy Method a transform után ellenőrzi; a Proc vagy Method a (transzformált) értéket kapja, truthy válasz esetén érvényes

Az elbukott validálás kiír egy üzenetet, és az egész flow-t megállítja: az :email azt, hogy "That doesn't look like a valid email address. Farewell.", a többi azt, hogy "Invalid input. Farewell.". Nem kérdez újra. A validate:-nek adott bármi mást soha nem ellenőriz (minden átmegy). Újrakérdezéshez vagy saját üzenethez call kell (lásd Újrakérdezés és jelszó).

RUBY
DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle', transform: :downcase, validate: :non_empty
  ask :email,  prompt: 'Email',  validate: :email
  ask :age,    prompt: 'Age',    transform: ->(answer) { answer.to_i }
  text { |ctx| "#{ctx[:handle]}, #{ctx[:age]}, #{ctx[:email]}" }
end

Saját szabály lambdával; az abc-ből a transform 0-t csinál, így elbukik:

RUBY
ADULTS = BBS::Flow.define do
  ask :age, prompt: 'Age', transform: ->(answer) { answer.to_i }, validate: ->(age) { age >= 18 }
  say 'Welcome in.'
end

Az ask-nek nincs mask: opciója; a jelszóprompt egy call lépés runner.readline(mask: '*')-gal.

pause

A pause(message, seconds: 1.0) :info stílussal kiírja a message-et, és alszik. A hívó nem tudja átugrani; legyen rövid.

RUBY
DEMO = BBS::Flow.define do
  pause 'Verifying identity...', seconds: 1.2
  say 'Verified.', style: :success
end

confirm

Igen/nem kérdés [yes/no] prompttal; a yes vagy y (kis- és nagybetűtől függetlenül) igen, minden más nem.

Blokk nélkül a confirm(message, denied: nil) ellenőrzőpont: igennél a flow megy tovább, nemnél kiírja a denied-et (ha van), és megáll.

Blokkal a confirm(message, denied: nil) { … } igennél al-flow-ként futtatja a blokkot; nemnél kiírja a denied-et (ha van), és a flow a blokk után folytatódik.

RUBY
DEMO = BBS::Flow.define do
  confirm 'Are you over 18?', denied: 'Come back later.'
  say 'Welcome to the adult area.'

  confirm 'Subscribe to the newsletter?', denied: 'Maybe next time.' do
    ask :email, prompt: 'Email', validate: :email
    say 'Subscribed.', style: :success
  end

  say 'Main menu next.'
end

Vezérlő lépések

gate

A gate(denied:) { |ctx| … } meghívja a blokkot a contexttel; ha az eredmény hamis, :error stílussal kiírja a denied-et, és megállítja a flow-t.

RUBY
BANNED = %w[spammer troll].freeze

DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle', transform: :downcase
  gate(denied: 'This handle is banned.') { |ctx| !BANNED.include?(ctx[:handle]) }
  say 'Come in.'
end

menu

A menu(prompt, loop: false) { option … } a menüpontok számozott BBS::Selector-a, 38 oszlop széles, hogy C64-en is elférjen, a prompt az állapotsorában áll. A kurzorbillentyűk és a számjegyek kijelölnek egy menüpontot, a RETURN kiválasztja, az Esc (C64-en a RUN/STOP vagy a ←) választás nélkül kilép a menüből. Minden option(label) { … } egy al-flow, ami a közös contexttel fut, ha kiválasztják. A blokk nélküli menüpont nem csinál semmit.

loop: A menüpont lépései után
false a menü kész; a flow utána folytatódik
true a menü újra megjelenik, amíg egy menüpont exit_menu-t nem futtat, vagy a hívó Esc-et nem nyom
RUBY
DEMO = BBS::Flow.define do
  menu 'Main menu', loop: true do
    option 'Bulletins' do
      say 'Nothing new.'
    end
    option 'Settings' do
      menu 'Settings' do
        option('Colours on')  { say 'Colours on.' }
        option('Colours off') { say 'Colours off.' }
      end
    end
    option 'Log off' do
      exit_menu
    end
  end
  say 'Goodbye!'
end

Egy menüponton belüli :halt (elbukott gate, :halt-ot adó call, bontás) az egész flow-t megállítja, nem csak a menüt.

exit_menu

Az exit_menu kilép a legbelső befoglaló menu-ből; a flow utána folytatódik. Beágyazott al-flow-ból is működik (egy menüponton belüli confirm blokkból). Menün kívül a flow végét jelenti. Egy call blokk nem adhatja vissza (a call-on csak a :halt jut át).

RUBY
DEMO = BBS::Flow.define do
  menu 'Pick', loop: true do
    option('Again') { say 'Once more.' }
    option('Done') do
      confirm 'Really leave the menu?' do
        exit_menu
      end
    end
  end
  say 'Out of the menu.'
end

A különbség: a (blokkos) confirm-re adott "no" egyszerűen továbbmegy, és a menü újra megjelenik.

persist

A persist(*fields, store:) a megnevezett context-mezőket egy BBS::Store-ba menti, a session-azonosító szerint: egy hívás első persist-je új sort ad hozzá, a későbbiek frissítik. A contextből hiányzó mezőket kihagyja; ha egy sincs meg, nem ír semmit. Csak olyan mezőt sorolj fel, ami a store oszlopa (headers:). A timestamp oszlopot, ha a store-nak van ilyen, a sor létrehozásának idejével tölti ki.

A 0.33 óta a store egy SQLite-fájl táblája: a .csv útvonal a mellette lévő .sqlite3 fájlt jelenti (data/guestbook.sqlite3), egy korábbi verzió CSV-fájlját pedig egyszer, üres táblánál beimportálja. Magáról a store-ról az Adat és hálózat oldal szól.

RUBY
GUESTBOOK = BBS::Store.new(path: File.join(__dir__, 'data/guestbook.csv'),
                           headers: %w[session_id timestamp name city message])

DEMO = BBS::Flow.define do
  ask :name, prompt: 'Name'
  ask :city, prompt: 'City'
  persist :name, :city, store: GUESTBOOK
  ask :message, prompt: 'Leave a message'
  persist :message, store: GUESTBOOK
  say 'Thanks for signing the guestbook.'
end

GUESTBOOK.all
# => [{ "session_id" => "3f9c2a1be07d4c11", "timestamp" => "2026-10-27T20:15:02Z",
#       "name" => "Zsolt", "city" => "Budapest", "message" => "Hello from the Harbor" }]

Kódlépések

call

A call { |ctx, runner| … } tetszőleges Ruby-kódot futtat. A blokk megkapja a contextet (szabadon olvashatod és írhatod) és a FlowRunner-t, amin át a vonalat éred el:

runner.
write(text) a hívónak ír (\r\n sorvégekkel)
line(text, style: :muted) egy sort ír két szóközzel behúzva, a stílus színével
ask(prompt) kiírja a prompt-ot, és beolvassa a levágott választ (C64-en BBS::Field-ben); nil, ha a hívó bontott
readline(mask: nil) egy sort olvas; mask: '*' mellett *-ot visszhangoz a karakterek helyett; nil, ha a hívó bontott
select(options, hint: nil) [címke, érték] párok számozott BBS::Selector-a; a választott értéket adja, Esc-re nil-t
context a context Hash (a blokk ctx-e)
session a BBS::Session: readkey, term_cols, term_rows, petscii?, enable_mouse, … (lásd Telnet és Store)
session_id a session-azonosító

:halt-tal megállítod a flow-t, bármi más visszatérési értékkel megy tovább. A blokkból korán kilépni next-tel lehet (nem return-nel). A kivétel a logba kerül, a hívó annyit lát, hogy "Something went wrong.", és a flow folytatódik; a megszakadt kapcsolat megállítja.

RUBY
DEMO = BBS::Flow.define do
  call do |ctx, runner|
    runner.line "Your terminal is #{runner.session.term_cols}x#{runner.session.term_rows}."
    ctx[:wide] = runner.session.term_cols >= 100
    nil
  end
  text { |ctx| ctx[:wide] ? 'Wide screen layout.' : 'Narrow screen layout.' }
end

Amit a megszűnt lépések csináltak, az itt pár sor. Egy lista, amiből választani lehet, egy RETURN-re várás, egy érték, amit egy kiírt sor alatt töltünk be:

RUBY
Post = Struct.new(:title, :body)
POSTS = [Post.new('Welcome', 'Hello all.'), Post.new('Rules', 'Be nice.')].freeze

DEMO = BBS::Flow.define do
  call do |_ctx, runner|
    post = runner.select(POSTS.map { |p| [p.title, p] }, hint: 'Read which post?')
    next nil unless post

    runner.line post.title, style: :prompt
    runner.line post.body
    runner.ask('Press RETURN')
    nil
  end
end

A call-lal futtat egy flow olyan dolgokat is, amik nem lépések: egy Application-t, ha kód is kell köré (APP.run(...), lásd app), egy másik flow-t (BBS::FlowRunner.new(runner.session, ctx[:session_id], OTHER, context: ctx).run), egy doort (BBS::Door.new(:machine, MACHINE).run(runner.session, ctx), FakeOS-gépet is), egy ZMODEM-letöltést, a board saját BBS::Petscii::Screen-jét.

Újrakérdezés és jelszó

Az ask rossz válaszra megáll; egy call-ciklus addig kérdez, amíg jó választ nem kap, és el is takarhatja a bevitelt:

RUBY
PINS = { 'sysop' => '1234' }.freeze

DEMO = BBS::Flow.define do
  call do |ctx, runner|
    loop do
      answer = runner.ask('  Handle (3-12 letters): ')
      break :halt if answer.nil?

      answer = answer.downcase
      if answer.match?(/\A[a-z]{3,12}\z/)
        ctx[:handle] = answer
        break nil
      end
      runner.line 'Letters only, 3 to 12 of them.', style: :error
    end
  end

  call do |ctx, runner|
    next nil unless PINS.key?(ctx[:handle])

    result = :halt
    3.times do
      runner.write '  PIN: '
      pin = runner.readline(mask: '*')
      break if pin.nil?

      if pin.strip == PINS[ctx[:handle]]
        ctx[:sysop] = true
        result = nil
        break
      end
      runner.line 'Wrong PIN.', style: :error
    end
    result
  end

  text { |ctx| "Welcome, #{ctx[:handle]}#{' (sysop)' if ctx[:sysop]}." }
end

A loop, times vagy each blokkjain belül a next csak a következő körre ugrik; ezekből break ÉRTÉK-kel lépj ki, és ez az érték (vagy a blokk utolsó kifejezése) legyen a call eredménye. A bontásnak (az ask vagy a readline nil-t ad) ki kell léptetnie a ciklusból, különben az örökké pörög.

app

Az app(definition) egy BBS::Application-nek adja át a vonalat (lásd Application): definition.run(session:, session_id:, context: ctx), amíg ki nem lépnek belőle, a doorjain át is (a választott door lefut, utána az Application újraépül). Az Application context-je ugyanez a Hash, így a változtatásokat a későbbi lépések is látják; ha megszakad a vonal, a flow megáll.

RUBY
DESKTOP = BBS::Application.define do
  status_hint('F10', 'Back') { :halt }
end

DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle'
  app DESKTOP
  say 'Back in the flow.'
end

Ugyanez egy call lépésből, ahogy a hálózatunk minden boardja csinálja, helyet hagy a futtatás köré írt kódnak:

RUBY
DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle'
  call do |ctx, runner|
    DESKTOP.run(session: runner.session, session_id: ctx[:session_id], context: ctx)
    nil
  end
  say 'Thanks for calling.'
end

Screenek (ERB)

A screen lépés kirenderel és kiír egy ERB-template-et. A kézzel rajzolt ANSI-címlapok helye a screen.

Hol van a fájl. A screen :welcome a welcome.erb-t a flow saját mappájából olvassa (BBS::Flow.define(screens_dir: DIR)), vagy ha a flow-nak nincs ilyen, a BBS.config.screens_dir-ből. Ha egyik sincs beállítva, a lépés kivételt dob (No screens_dir: …), és a session véget ér. Egy gemben szállított flow a saját mappáját adja meg, így bármelyik boardban ki tudja rajzolni a screenjeit.

Változók. A template-ben a context minden kulcsa példányváltozó (@session_id, @name, …), és a lépés vars-a is. Ha egy vars-érték Symbol, a contextből veszi az értékét (a node: :session_id a @node-ba a session-azonosítót teszi); minden más változatlanul megy át.

A template-ben elérhető helperek:

Helper Mit ad
clr képernyőtörlés és kurzor a bal felső sarokba (\e[2J\e[H)
reset \e[0m
green, yellow, cyan, white, red a paletta (BBS::Ansi) élénk színei
gray, dim_green sima szürke és normál zöld
banner(text, padding: 2) zöld, dupla vonalas keret, mint a banner lépés
big_banner(text, font: 'slant') zöld FIGlet-art, mint a big_banner lépés

Az ERB trim_mode: '-'-dal fut, így a -%> lenyeli a tag utáni sortörést; az eredmény minden \n-je \r\n-ként megy ki.

screens/welcome.erb:

ERB
<%= clr -%>
<%= big_banner('Harbor') %>

  <%= cyan %>The Harbor BBS<%= reset %>  <%= gray %>node <%= @node %>, <%= @year %><%= reset %>
<% if @name -%>
  <%= white %>Welcome back, <%= @name %>.<%= reset %>
<% end -%>

A flow:

RUBY
DEMO = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
  screen :welcome, year: Time.now.year, node: :session_id
  ask :name, prompt: 'Name'
  screen :welcome, year: Time.now.year, node: :session_id
end

Ugyanez a renderer közvetlenül is hívható, pl. egy call-ból vagy egy tesztből: a BBS::Renderer.render('welcome', { year: 2026 }, dir: 'screens') Stringet ad vissza.

Bannerek

A banner és a big_banner lépés, valamint a template-helperek mind a BBS::Banner-ből jönnek, amit magad is hívhatsz (egy call-ban, egy doorban):

Metódus Mit ad vissza
BBS::Banner.render(text, color: BBS::Ansi::BRIGHT_GREEN, padding: 2) dupla vonalas keret a text körül, két szóközzel behúzva, \r\n sorvégekkel, előtte és utána üres sorral
BBS::Banner.render_big(text, color: BBS::Ansi::BRIGHT_GREEN, font: 'slant') a text FIGlet-artja (font nevű artii-font), minden sor két szóközzel behúzva és színezve, \r\n sorvégekkel

A color ANSI SGR-szekvencia, általában a paletta egy színe (BBS::Ansi::CYAN); egy stílus színét a BBS::FlowRunner::STYLES[:info] adja.

RUBY
require 'bbs'

puts BBS::Banner.render('HELLO', color: BBS::FlowRunner::STYLES[:info])
puts BBS::Banner.render_big('BBS', font: 'small')

DEMO = BBS::Flow.define do
  call { |_ctx, runner| runner.write BBS::Banner.render_big('NEWS', color: BBS::Ansi::BRIGHT_CYAN, font: 'standard') }
end
TEXT
  ╔═════════╗
  ║  HELLO  ║
  ╚═════════╝

   ___ ___ ___
  | _ ) _ ) __|
  | _ \ _ \__ \
  |___/___/___/

Egy teljes board: login és menü

Egy board üdvözlőképernyővel, sysop-PIN-es loginnal, egy felhasználói store-ral, egy üzenetolvasóval és egy fiókoldallal. A mappa:

TEXT
harbor/
├── bbs.rb
├── screens/welcome.erb   # a fenti "Screenek (ERB)" template-je
└── data/                 # itt jön létre a users.sqlite3

bbs.rb:

RUBY
require 'bbs'

USERS = BBS::Store.new(path: File.join(__dir__, 'data/users.csv'),
                       headers: %w[session_id timestamp handle email])

Post = Struct.new(:title, :author, :body)
POSTS = [
  Post.new('Welcome aboard', 'sysop', 'This is the Harbor. Be nice, keep it short.'),
  Post.new('Door games', 'sysop', 'Three doors are coming next month.')
].freeze

PINS = { 'sysop' => '1234' }.freeze

READ_POSTS = lambda do |_ctx, runner|
  loop do
    post = runner.select(POSTS.map { |p| ["#{p.title} (#{p.author})", p] }, hint: 'Read which post?')
    break nil unless post

    runner.line post.title, style: :prompt
    runner.line post.body
    break :halt if runner.ask('  RETURN for the list: ').nil?
  end
end

MAIN = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
  screen :welcome, year: Time.now.year, node: :session_id

  banner 'LOGIN', style: :info
  ask :handle, prompt: 'Handle', transform: :downcase, validate: :non_empty

  call do |ctx, runner|
    next nil unless PINS.key?(ctx[:handle])

    runner.write '  PIN: '
    pin = runner.readline(mask: '*')
    next :halt if pin.nil?

    ctx[:sysop] = pin.strip == PINS[ctx[:handle]]
    nil
  end

  gate(denied: 'Wrong PIN.') { |ctx| !PINS.key?(ctx[:handle]) || ctx[:sysop] }
  persist :handle, store: USERS
  banner 'ACCESS GRANTED'

  menu 'Main menu', loop: true do
    option('Read posts') { call(&READ_POSTS) }

    option 'Your account' do
      text { |ctx| "Handle:  #{ctx[:handle]}" }
      text { |ctx| "Session: #{ctx[:session_id]}" }
      text { |ctx| "Sysop:   #{ctx[:sysop] ? 'yes' : 'no'}" }
      confirm 'Subscribe to the newsletter?', denied: 'Maybe next time.' do
        ask :email, prompt: 'Email', validate: :email
        persist :email, store: USERS
        say 'Subscribed.', style: :success
      end
    end

    option 'Log off' do
      exit_menu
    end
  end

  say 'Fair winds!', style: :info
end

BBS.configure do |c|
  c.flow = MAIN
end

BBS.start

Ha a hívó feliratkozott, a USERS.find(session_id) ezt adja: { "session_id" => "3f9c2a1be07d4c11", "timestamp" => "2026-10-27T22:17:56Z", "handle" => "sysop", "email" => "a@b.hu" }; a sor a data/users.sqlite3-ban van.

Egy valódi board: Impostor

Az Impostor BBS a Definitely not an Impostor játék debrief-terminálja. A teljes párbeszéde egyetlen Flow egy gemben (lib/impostor.rb), saját screens/ mappával, így ugyanaz a Flow önálló boardként és egy másik board doorjaként is fut (door :impostor, Impostor::Modern.flow(sessions: store), label: '&Impostor'). A két arc két modul, az Impostor::Modern és az Impostor::Petscii, mindkettő a saját screenjeivel (screens/modern/, screens/petscii/, utóbbi 40 oszlopra rajzolva).

RUBY
require 'bbs'
require_relative 'impostor/code_generator'

module Impostor
  SCREENS = File.expand_path('../screens', __dir__)

  SESSION_HEADERS = %w[session_id timestamp player_name code feedback email].freeze

  def self.sessions_store(path) = BBS::Store.new(path: path, headers: SESSION_HEADERS)

  # The flow both faces share; +screens_dir+ holds the face's screens.
  def self.debrief(sessions:, screens_dir:)
    BBS::Flow.define(screens_dir: screens_dir) do
      screen :welcome

      banner "IDENTITY VERIFICATION", style: :info

      ask :player_name, prompt: "Player Name", transform: :upcase
      ask :code,        prompt: "Code"
      persist :player_name, :code, store: sessions

      pause "Verifying identity...", seconds: 1.2

      gate denied: "Access denied. Code mismatch." do |ctx|
        CodeGenerator.decrypt(ctx[:code]) == ctx[:player_name][0, CodeGenerator::NAME_LEN]
      end

      confirm "Norman, is that you?", denied: "Very well. Goodbye." do
        screen :congrats

        menu ">", loop: true do
          option "Leave feedback" do
            ask :feedback, prompt: "Feedback"
            persist :feedback, store: sessions
            say "Feedback recorded. Thank you.", style: :success
          end

          option "Subscribe to updates" do
            ask :email, prompt: "Email address", validate: :email
            persist :email, store: sessions
            say "Perfect. We'll be in touch.", style: :success
          end

          option "Exit" do
            exit_menu
          end
        end
      end
    end
  end

  module Modern
    def self.flow(sessions:) = Impostor.debrief(sessions: sessions, screens_dir: File.join(SCREENS, 'modern'))
  end

  module Petscii
    def self.flow(sessions:) = Impostor.debrief(sessions: sessions, screens_dir: File.join(SCREENS, 'petscii'))
  end
end

Amit érdemes észrevenni: a flow-t egy metódus építi, így a store és az arc paraméter, nem globális; a kódot még a gate előtt menti, így a sikertelen próbálkozások is megmaradnak; a board többi része a confirm blokkjában van, így a "no" udvariasan lezárja; a board bbs.rb-je pedig csak összeköti:

RUBY
require 'bundler/setup'
require_relative 'lib/impostor'

sessions = Impostor.sessions_store(File.expand_path('data/sessions.csv', __dir__))

BBS.configure do |c|
  c.flow = BBS::Terminal.flow(title: 'Impostor BBS',
                              modern: Impostor::Modern.flow(sessions: sessions),
                              petscii: Impostor::Petscii.flow(sessions: sessions))
end

BBS.start

Flow futtatása kézzel

A BBS::FlowRunner.new(session, session_id, flow, context: {}).run bármelyik flow-t lefuttatja egy sessionön, és a végén visszatér; a visszatérési érték :halt, ha megállt, egyébként nil. Így futtatja a BBS::Terminal.flow a hívó által választott arcot, és így fut egy flow-door egy Applicationön belül. Egy call lépésből:

RUBY
RULES = BBS::Flow.define do
  banner 'RULES', style: :info
  text '1. Be nice.'
  text '2. No spam.'
  pause 'Back to the board...', seconds: 2
end

DEMO = BBS::Flow.define do
  ask :handle, prompt: 'Handle'
  call do |ctx, runner|
    BBS::FlowRunner.new(runner.session, ctx[:session_id], RULES, context: ctx).run
  end
  say 'Rules read.'
end

Ha a call a runner eredményét adja vissza, a belső flow :halt-ja a külsőre is átjut. A belső flow saját contextet kap, a ctx-ből feltöltve (a kulcsok másolata: amit a belső flow ask-ol, az nem jelenik meg a külső ctx-ben).

Lásd még