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:
@@ -6,6 +6,7 @@ require_relative 'bbs/telnet'
|
|||||||
require_relative 'bbs/banner'
|
require_relative 'bbs/banner'
|
||||||
require_relative 'bbs/flow'
|
require_relative 'bbs/flow'
|
||||||
require_relative 'bbs/flow_runner'
|
require_relative 'bbs/flow_runner'
|
||||||
|
require_relative 'bbs/fake_os'
|
||||||
require_relative 'bbs/renderer'
|
require_relative 'bbs/renderer'
|
||||||
require_relative 'bbs/store'
|
require_relative 'bbs/store'
|
||||||
require_relative 'bbs/frame_buffer'
|
require_relative 'bbs/frame_buffer'
|
||||||
|
|||||||
238
lib/bbs/fake_os.rb
Normal file
238
lib/bbs/fake_os.rb
Normal 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
|
||||||
@@ -126,6 +126,10 @@ module BBS
|
|||||||
def app(definition)
|
def app(definition)
|
||||||
@steps << { type: :app, definition: definition }
|
@steps << { type: :app, definition: definition }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def os(definition)
|
||||||
|
@steps << { type: :os, definition: definition }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class MenuBuilder
|
class MenuBuilder
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ module BBS
|
|||||||
when :output then run_output(step)
|
when :output then run_output(step)
|
||||||
when :tui then run_tui(step)
|
when :tui then run_tui(step)
|
||||||
when :app then run_app(step)
|
when :app then run_app(step)
|
||||||
|
when :os then run_os(step)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -359,6 +360,13 @@ module BBS
|
|||||||
:halt
|
:halt
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def run_os(step)
|
||||||
|
FakeOS::Runner.new(@session, step[:definition], context: @ctx).run
|
||||||
|
nil
|
||||||
|
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
||||||
|
:halt
|
||||||
|
end
|
||||||
|
|
||||||
# ── helpers ────────────────────────────────────────────────────────────────
|
# ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def strip_md(text)
|
def strip_md(text)
|
||||||
|
|||||||
Reference in New Issue
Block a user