app/console/cart_loader.rb

the library's own source, console 0.2.0 · back to Console::CartLoader

# Console::CartLoader -- finds a cart, requires it, and starts the console.
#
# A "cart" is a directory under carts/ that defines one module (or class)
# implementing the cart contract:
#
#   carts/space/
#     app/space.rb        the cart: one module/class named after the directory
#     sprites/            its own art
#     sounds/  maps/  data/
#
# Only the selected cart is ever required, so a cart is genuinely a standalone
# program: nothing else in carts/ is parsed at runtime, so a broken cart can
# never stop the console from booting, and two carts can both have a
# `sprites/hero.png` without seeing each other's.
#
# The contract is intentionally tiny. All of these hooks are optional:
#
#   def self.assets    # { sprites: {...}, sounds: {...}, music: {...} }
#   def setup         # once, at boot
#   def update        # every frame (when the cart defines no scenes)
#   def render        # every frame, always last: use it for a HUD
#
# And it may define scenes instead of `update`:
#
#   scene :title, TitleScene
#
# Every API method in Console::API is available as a bare call inside a cart.
# Paths a cart writes are relative to its own directory -- `sprites/hero.png`
# means carts/space/sprites/hero.png -- which Console::Assets resolves.
module Console
  class CartLoader
    CARTS_DIR = 'carts'
    DEFAULT_CART = 'selftest'

    attr_reader :args, :errors

    def initialize(args)
      @args = args
      @errors = []
      @selected = nil
      @cart = nil
      @pinned = nil
    end

    def cli
      @cli ||= DR.cli_arguments
    end

    # Pin the loader to exactly one cart.
    #
    # This is how a published single-cart build boots: ./bin/publish-cart stages a
    # directory holding one cart and an entry point that pins it, so the cart
    # cannot be swapped at runtime.
    #
    # The pin accepts a name or a path, exactly like --cart does, so a staged
    # build pins 'carts/space' and keeps working if the cart is ever moved.
    #
    # A pin outranks --cart and $CART. In a packaged build there is no terminal
    # to type at, and the whole point is that what you shipped is what runs.
    def pin(target)
      @pinned = target.to_s
      self
    end

    def pinned?
      !@pinned.nil? && !@pinned.to_s.empty?
    end

    def pinned_name
      @pinned
    end

    # --- discovery --------------------------------------------------------

    # Turn whatever was asked for into a cart directory.
    #
    # A bare name means the gallery: 'space' is carts/space. Anything with a
    # path separator is taken as a path, which is what lets a cart live in
    # games/ or demos/ instead of carts/ without the console caring.
    def cart_dir(target)
      s = Console::Str.chomp_slash target.to_s
      return nil if s.empty?
      return s if s.include? '/'
      "#{CARTS_DIR}/#{s}"
    end

    # The cart's name is the last segment of its directory, which is also the
    # module name resolve_cart_module looks for.
    def cart_name(dir)
      Console::Str.basename dir
    end

    # A cart's entry file.
    #
    # app/main.rb is preferred -- "the entry point" means the same thing here as
    # it does at the console root, and it drops the name stutter. app/<name>.rb
    # is still accepted so a cart written the old way keeps working.
    def cart_entry(dir)
      base = "#{dir}/app"
      main = "#{base}/main.rb"
      return main if DR.stat_file main
      "#{base}/#{cart_name(dir)}.rb"
    end

    # Is this a bootable cart?
    def cart?(dir)
      return false if dir.nil?
      !DR.stat_file(cart_entry(dir)).nil?
    end

    # Cart names in the default gallery, sorted. Only directories with an entry
    # file count, so a half-created cart or a stray README in carts/ is simply
    # not a cart.
    def available
      return [] unless DR.stat_file(CARTS_DIR)
      names = []
      DR.list_files(CARTS_DIR).each do |entry|
        info = DR.stat_file "#{CARTS_DIR}/#{entry}"
        next unless info
        next unless info[:file_type] == :directory
        next unless cart?("#{CARTS_DIR}/#{entry}")
        names << entry
      end
      names.sort
    end

    # Explicit wins, then the env var, then a deterministic fallback.
    #
    # A pin beats everything: a published build is pinned, and there is nothing
    # sensible for it to fall back *to*.
    #
    # The value is whatever was asked for -- a bare name or a path -- and it is
    # left that way so callers can report exactly what the user typed. Use
    # requested_dir to get the directory it resolves to.
    def requested_name
      return @pinned if pinned?
      name = cli[:cart] if cli.key? :cart
      name = DR.getenv('CART') if name.nil? || name.to_s.empty?
      name = DEFAULT_CART if name.nil? || name.to_s.empty?
      name.to_s
    end

    # The directory the requested target resolves to.
    def requested_dir
      cart_dir requested_name
    end

    def selected_name
      @selected
    end

    # --- running ----------------------------------------------------------

    def run
      handle_list if switch?(:list)

      target = requested_name
      dir = cart_dir target

      unless cart? dir
        # A pinned cart that is missing is a packaging bug, not a typo to paper
        # over. Booting some other cart here would ship a game that is not the
        # one that was asked for, so fail loudly instead.
        if pinned?
          abort_boot "pinned cart '#{target}' is not a cart directory (looked for #{cart_entry(dir)})"
          return nil
        end

        available_names = available
        fallback = available_names.include?(DEFAULT_CART) ? DEFAULT_CART : available_names.first
        if fallback.nil?
          abort_boot "no carts found in #{CARTS_DIR}"
          return nil
        end
        # DragonRuby resolves every file against the game directory and will not
        # stat a path that escapes it, so naming a cart outside this console
        # fails however it is spelled. Say so, rather than implying a typo.
        outside = dir.to_s.start_with?('/') || dir.to_s.start_with?('..')
        hint = outside ? ' (a cart has to sit inside the console)' : ''
        puts "[console] cart '#{target}' not found#{hint}; booting '#{fallback}' instead."
        target = fallback
        dir = cart_dir target
      end

      name = cart_name dir
      @selected = name
      path = cart_entry dir

      # Scope assets before anything is required. A cart may declare its assets
      # at load time, and it must already be looking at its own directory.
      Console::Assets.boot dir

      begin
        require path
        require_cart_files dir, path
      rescue => e
        abort_boot "could not require #{path}: #{e.class}: #{e.message}"
        return nil
      end

      cart = resolve_cart_module name
      if cart.nil?
        abort_boot "#{path} did not define a cart module"
        return nil
      end

      instance = instantiate cart
      return nil if instance.nil?
      @cart = instance

      # Boot first, then honour switches. The self test asserts against live
      # subsystems, so it can only run once the console is fully wired.
      Console.boot @args, instance, name
      apply_switches

      # --selftest implies the selftest cart, whichever cart booted. A pinned
      # build ships exactly one cart, so if that cart is not the selftest there
      # is nothing to require -- and requiring a missing file would crash the
      # published build rather than report a clean skip.
      if switch?(:selftest) && !pinned?
        summary = run_selftest
        puts Console::Test.summary_line(summary)
        quit_now summary[:status] == 'PASS'
      end

      cart
    end

    # Require the rest of the cart's own top-level code files.
    #
    # A cart is allowed to be more than one file -- that is what app/ is for --
    # and the entry file is always required first, so a cart can define things
    # its siblings reference at load time. Only this cart's directory is read,
    # which is the property that keeps one broken cart from taking the console
    # down with it. Sub-directories are the cart's to require itself.
    def require_cart_files(cart, entry)
      dir = "#{cart}/app"
      return unless DR.stat_file dir
      extra = []
      DR.list_files(dir).each do |file|
        next unless file.end_with? '.rb'
        path = "#{dir}/#{file}"
        next if path == entry
        extra << path
      end
      extra.sort.each { |p| require p }
    end

    # Run the built-in suites.
    #
    # The suites assert against asset files the selftest cart owns, so the
    # asset scope moves to that cart even when a different one booted: a test
    # that resolved a fixture against whichever cart happened to boot would
    # quietly assert nothing useful.
    def run_selftest
      dir = cart_dir 'selftest'
      Console::Assets.boot dir
      require cart_entry(dir)
      Console::Test.run @args
    end

    # A cart file may define a Class (hooks as instance methods) or a Module
    # (hooks as singleton methods). Classes are instantiated so that carts can
    # hold per-run state.
    def instantiate(cart)
      return cart unless cart.is_a?(Class)
      cart.new
    rescue => e
      abort_boot "could not construct #{cart}: #{e.class}: #{e.message}"
      nil
    end

    # A cart file may define either a module or a class named after itself.
    def resolve_cart_module(name)
      candidates = [name, Console::Str.camel(name)]
      candidates += ["Cart#{candidates[1]}"]
      candidates.each do |const|
        begin
          value = Object.const_get const
          return value if value.respond_to?(:setup) || value.is_a?(Module)
        rescue
          next
        end
      end
      nil
    end

    def apply_switches
      Console.show_hud = true if switch?(:hud)

      ticks = cli[:ticks]
      Console.quit_after = ticks.to_i if ticks && !ticks.to_s.empty?

      # --scene <name> jumps straight into a scene. Handy while iterating on
      # gameplay: you do not have to play through the title screen every time.
      scene = cli[:scene]
      if scene && !scene.to_s.empty?
        Console.goto_scene scene
      end

      shot = cli[:shot]
      if shot && !shot.to_s.empty?
        at = cli[:shot_at]
        at = at.to_i unless at.nil?
        # DragonRuby allots a frame to actually encode the PNG, so a screenshot
        # requested on the same tick as the quit would be lost. Leave it a few
        # frames of runway.
        at = (Console.quit_after - 3) if at.nil? && Console.quit_after
        Console.screenshot shot, at
      end
    end

    # --- switches ---------------------------------------------------------

    def handle_list
      names = available
      puts ''
      puts 'Available carts:'
      names.each do |n|
        mark = n == DEFAULT_CART ? ' (default)' : ''
        title = cart_title cart_dir(n)
        puts "  #{n}#{mark}#{title ? "  -- #{title}" : ''}"
      end
      puts ''
      puts "Run one with:  ./bin/run #{CARTS_DIR}/<name>"
      quit_now
    end

    # Read a cart's title without requiring it: grep the source.
    def cart_title(dir)
      info = DR.stat_file cart_entry(dir)
      return nil unless info
      source = DR.read_file cart_entry(dir)
      return nil unless source
      Console::Str.quoted_value_after(source, 'TITLE')
    end

    def handle_test
      summary = run_selftest
      puts Console::Test.summary_line(summary)
      quit_now(summary[:status] == 'PASS')
    end

    def abort_boot(message)
      puts "[console] BOOT ERROR: #{message}"
      @errors << message
      DR.request_quit
    end

    def quit_now(ok = true)
      DR.request_quit
      @quit_status = ok ? 0 : 1
    end

    # Is a switch present?
    #
    # DR.cli_arguments stores a bare switch (`--list`) as a key with a nil
    # value, which is indistinguishable from an absent key by lookup alone --
    # so the key's presence is what counts, and only an explicit falsey value
    # turns it off (`--list=false`).
    def switch?(key)
      return false unless cli.key? key
      value = cli[key]
      return true if value.nil?
      v = value.to_s.downcase
      !(v == 'false' || v == '0' || v == 'no')
    end
  end
end