Add FakeOS: configurable character-mode OS simulator

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 <definition>`) alongside tui/app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 20:04:59 +02:00
parent 6b320318ca
commit 4d38046c70
4 changed files with 251 additions and 0 deletions

View File

@@ -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'

238
lib/bbs/fake_os.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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)