From 4d38046c7056e0edbad02f7046ce83522124dccd Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Fri, 5 Jun 2026 20:04:59 +0200 Subject: [PATCH] Add FakeOS: configurable character-mode OS simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BBS::FakeOS is a generic, teletype-style engine for simulating retro operating systems: a scripted boot sequence (BIOS POST, memory test, banners, typewriter output) followed by an interactive command shell. Each simulated OS (MS-DOS, CP/M, AmigaDOS CLI, ...) is just a FakeOS.define { ... } config — prompt, colors, boot block, command table and an on_unknown fallback — so new OSes are added as configuration, not code. on_unknown is the natural hook for a conversational/LLM-backed shell. Exposed as a Flow step (`os `) alongside tui/app. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/bbs.rb | 1 + lib/bbs/fake_os.rb | 238 +++++++++++++++++++++++++++++++++++++++++ lib/bbs/flow.rb | 4 + lib/bbs/flow_runner.rb | 8 ++ 4 files changed, 251 insertions(+) create mode 100644 lib/bbs/fake_os.rb diff --git a/lib/bbs.rb b/lib/bbs.rb index 0e79801..5879ae9 100644 --- a/lib/bbs.rb +++ b/lib/bbs.rb @@ -6,6 +6,7 @@ require_relative 'bbs/telnet' require_relative 'bbs/banner' require_relative 'bbs/flow' require_relative 'bbs/flow_runner' +require_relative 'bbs/fake_os' require_relative 'bbs/renderer' require_relative 'bbs/store' require_relative 'bbs/frame_buffer' diff --git a/lib/bbs/fake_os.rb b/lib/bbs/fake_os.rb new file mode 100644 index 0000000..afb90b6 --- /dev/null +++ b/lib/bbs/fake_os.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require_relative 'banner' + +module BBS + # A configurable, teletype-style character-mode OS simulator: a scripted + # boot sequence followed by an interactive command shell. Each simulated OS + # (MS-DOS, CP/M, an AmigaDOS CLI, …) is just a different Definition — the + # engine here is generic, so new "operating systems" are added as config, + # not as new code. + # + # DOS = BBS::FakeOS.define do + # name 'MS-DOS' + # prompt 'C:\\>' + # colors text: "\e[0;37m", accent: "\e[1;32m" + # + # boot do + # print 'Award Modular BIOS v4.51PG', delay: 0.4 + # memtest 16_384 + # pause 0.5 + # cls + # big_banner 'MS-DOS' + # end + # + # ready { puts 'Type HELP for a list of commands.' } + # + # command 'ver' { |_args, io| io.puts 'MS-DOS Version 6.22' } + # command 'cls' { |_args, io| io.cls } + # command 'exit', 'quit' { |_args, io| io.halt } + # + # on_unknown do |input, io| + # io.puts "Bad command or file name" + # end + # end + # + # BBS::FakeOS::Runner.new(session, DOS).run + # + # The boot/ready blocks run against a Console (so primitives like `print`, + # `cls`, `pause`, `banner`, `type` are available without a receiver). + # Command and on_unknown blocks are called with `(args_or_input, console)` — + # use the console (`io`) for all output. `on_unknown` is the natural hook for + # a conversational / LLM-backed shell: anything that is not a registered + # command lands there. + module FakeOS + DEFAULT_COLORS = { + text: "\e[0;37m", # light grey on black — the classic console look + bright: "\e[1;37m", + accent: "\e[1;32m", + error: "\e[1;31m", + dim: "\e[1;30m", + prompt: "\e[0;37m", + }.freeze + + def self.define(&block) + definition = Definition.new + definition.instance_eval(&block) + definition + end + + # Immutable-ish description of one simulated OS, built via the define DSL. + class Definition + def initialize + @name = 'OS' + @prompt = '> ' + @colors = DEFAULT_COLORS.dup + @boot_block = nil + @ready_block = nil + @commands = {} + @order = [] + @unknown = nil + end + + # `name 'MS-DOS'` to set, `name` to read. + def name(value = nil) + value.nil? ? @name : (@name = value) + end + + # Prompt may be a String or a `->(ctx) { ... }` block evaluated per line. + def prompt(value = nil, &block) + if block then @prompt = block + elsif !value.nil? then @prompt = value + else @prompt + end + end + + # Merge SGR overrides, e.g. `colors text: "\e[0;32m"`. Read with no args. + def colors(overrides = nil) + overrides ? @colors.merge!(overrides) : @colors + end + + def boot(&block) = (@boot_block = block) + def ready(&block) = (@ready_block = block) # runs once after boot, before the first prompt + + # Register a command under one or more names (the first is the canonical + # one shown in help). The block receives `(args_string, console)`. + def command(*names, help: nil, &block) + primary = names.first.to_s + @order << primary unless @order.include?(primary) + names.each do |n| + @commands[n.to_s.downcase] = { name: primary, help: help, block: block } + end + end + + # Fallback for input that matches no command. Block gets `(input, console)`. + def on_unknown(&block) = (@unknown = block) + + attr_reader :boot_block, :ready_block, :commands, :order, :unknown + end + + # The live terminal: owns the telnet session and all output primitives, and + # drives the boot sequence + shell loop. Handlers receive this as `io`. + class Console + attr_reader :ctx, :session, :definition + + def initialize(session, definition, context) + @session = session + @definition = definition + @ctx = context + @colors = definition.colors + @running = true + end + + def run + instance_eval(&@definition.boot_block) if @definition.boot_block + instance_eval(&@definition.ready_block) if @definition.ready_block + run_shell + end + + # ── output primitives (also available unqualified inside boot/ready) ───── + + # Write a line (or blank line) terminated by CRLF. + def puts(text = '', style: :text) + @session.write("#{color(style)}#{text}#{reset}\r\n") + end + + # Write text followed by `newline` CRLFs, then sleep `delay` seconds. + # Handy for boot messages: `print 'POST OK', delay: 0.3`. + def print(text = '', delay: 0, style: :text, newline: 1) + @session.write("#{color(style)}#{text}#{reset}#{"\r\n" * newline}") + sleep_for(delay) + end + + # Typewriter effect — emit one character at a time at `cps` chars/second. + def type(text, style: :text, cps: 900, newline: 1) + delay = cps.positive? ? 1.0 / cps : 0 + @session.write(color(style)) + text.to_s.each_char do |ch| + @session.write(ch) + sleep_for(delay) + end + @session.write("#{reset}#{"\r\n" * newline}") + end + + def newline(count = 1) = @session.write("\r\n" * count) + + def cls = @session.write("\e[2J\e[H") + + def pause(seconds) = sleep_for(seconds) + + # Boxed single-line banner (BBS::Banner). + def banner(text, style: :accent) + @session.write(Banner.render(text, color: color(style))) + end + + # Big figlet banner (BBS::Banner / artii). + def big_banner(text, style: :accent, font: 'standard') + @session.write(Banner.render_big(text, color: color(style), font: font)) + end + + # The classic counting memory test: `memtest 16_384` → + # "Memory Test : 16384K OK" counting up in place. + def memtest(kilobytes, step: 2048, delay: 0.03, label: 'Memory Test : ') + counted = 0 + while counted < kilobytes + counted = [counted + step, kilobytes].min + @session.write("\r#{color(:text)}#{label}#{counted}K #{reset}") + sleep_for(delay) + end + @session.write("#{color(:text)}#{label}#{kilobytes}K OK#{reset}\r\n") + end + + def cols = (@session.term_cols || 80) + def rows = (@session.term_rows || 24) + + # Stop the shell loop (used by an `exit`/`quit` command). + def halt = (@running = false) + + private + + def run_shell + while @running + @session.write("#{color(:prompt)}#{resolve_prompt}#{reset}") + line = @session.readline + break if line.nil? + dispatch(line.strip) + end + end + + def dispatch(input) + return if input.empty? + word, rest = input.split(/\s+/, 2) + entry = @definition.commands[word.downcase] + if entry + entry[:block].call(rest.to_s, self) + elsif @definition.unknown + @definition.unknown.call(input, self) + else + puts 'Bad command or file name', style: :error + end + end + + def resolve_prompt + prompt = @definition.prompt + prompt.respond_to?(:call) ? prompt.call(@ctx).to_s : prompt.to_s + end + + def color(role) = @colors.fetch(role) { @colors[:text] } + def reset = "\e[0m" + def sleep_for(seconds) = (sleep(seconds) if seconds&.positive?) + end + + # Entry point: plays the boot sequence then runs the shell until the user + # quits or disconnects. + class Runner + def initialize(session, definition, context: {}) + @session = session + @definition = definition + @context = context + end + + def run + Console.new(@session, @definition, @context).run + rescue IOError, Errno::EPIPE, Errno::ECONNRESET + nil + end + end + end +end diff --git a/lib/bbs/flow.rb b/lib/bbs/flow.rb index 8525923..3048e80 100644 --- a/lib/bbs/flow.rb +++ b/lib/bbs/flow.rb @@ -126,6 +126,10 @@ module BBS def app(definition) @steps << { type: :app, definition: definition } end + + def os(definition) + @steps << { type: :os, definition: definition } + end end class MenuBuilder diff --git a/lib/bbs/flow_runner.rb b/lib/bbs/flow_runner.rb index 43f890b..18a69cc 100644 --- a/lib/bbs/flow_runner.rb +++ b/lib/bbs/flow_runner.rb @@ -72,6 +72,7 @@ module BBS when :output then run_output(step) when :tui then run_tui(step) when :app then run_app(step) + when :os then run_os(step) end end @@ -359,6 +360,13 @@ module BBS :halt end + def run_os(step) + FakeOS::Runner.new(@session, step[:definition], context: @ctx).run + nil + rescue IOError, Errno::EPIPE, Errno::ECONNRESET + :halt + end + # ── helpers ──────────────────────────────────────────────────────────────── def strip_md(text)