A rubbs Flow rétege: sororientált, deklaratív párbeszéd, amivel minden board kezdődik. Ez az oldal a
BBS::Flowminden 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.
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.
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.
A runner sorban futtatja a lépéseket, amíg az alábbiak egyike be nem következik:
config.flow esetén ez azt jelenti, hogy a szerver bontja a vonalat.: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.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é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).
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.
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.
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
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.
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.
A banner(text, style: :success) dupla vonalas keretbe (╔═╗ ║ ╚═╝) írja a text-et, két szóköz térközzel.
DEMO = BBS::Flow.define do
banner 'IDENTITY VERIFICATION', style: :info
end
╔═════════════════════════╗
║ IDENTITY VERIFICATION ║
╚═════════════════════════╝
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.
DEMO = BBS::Flow.define do
big_banner 'HARBOR'
big_banner 'BBS', font: 'doom', style: :info
end
A screen(name, **vars) kirendereli és kiírja a NAME.erb fájlt. Lásd lent a Screenek (ERB) részt.
DEMO = BBS::Flow.define(screens_dir: File.join(__dir__, 'screens')) do
screen :welcome, year: Time.now.year, node: :session_id
end
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ó).
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:
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.
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.
DEMO = BBS::Flow.define do
pause 'Verifying identity...', seconds: 1.2
say 'Verified.', style: :success
end
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.
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
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.
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
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 |
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.
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).
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.
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.
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" }]
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.
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:
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.
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:
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.
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.
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:
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
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:
<%= clr -%>
<%= big_banner('Harbor') %>
<%= cyan %>The Harbor BBS<%= reset %> <%= gray %>node <%= @node %>, <%= @year %><%= reset %>
<% if @name -%>
<%= white %>Welcome back, <%= @name %>.<%= reset %>
<% end -%>
A flow:
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.
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.
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
╔═════════╗
║ HELLO ║
╚═════════╝
___ ___ ___
| _ ) _ ) __|
| _ \ _ \__ \
|___/___/___/
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:
harbor/
├── bbs.rb
├── screens/welcome.erb # a fenti "Screenek (ERB)" template-je
└── data/ # itt jön létre a users.sqlite3
bbs.rb:
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.
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).
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:
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
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:
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).
app lépéssel vagy APP.run-nalBBS::FakeOS gépekBBS::Terminal.flow, PETSCII-arcok, BBS::Petscii::Screen és BBS::SelectorBBS::Store, BBS::SQLite, BBS.logrunner.session, billentyűk