summaryrefslogtreecommitdiffstats
path: root/CLI/ruby/lib/game_of_life.rb
blob: 2ff548c7d13686d8b0ca7b06e9cdadc0aa20b008 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# frozen_string_literal: true

require "io/console"
require "game_of_life/version"
require "game_of_life/plane"
require "game_of_life/universe"
require "game_of_life/cell"

# Game of Life module entry point
module GameOfLife
  # Base Error class
  class Error < StandardError; end

  class << self
    # Validate and parse the CLI options and set defaults for the dynamic options.
    # @param options [Hash] CLI options to generate initial conditions
    # @option options [String | nil] "input" path to a local file or URL to open
    # @option options [Numeric | nil] "seed" number to use if no "input" is provided
    # @option options [Numeric] "width" of the universe
    # @option options [Numeric] "height" of the universe
    # @option options [Numeric] "delay" introduced after each cycle
    # @option options [String] "live-cell" representation
    # @option options [String] "dead-cell" representation
    # @smell AbcSize, MethodLenght, Cyclomatic and Perceived Complexity needed for parsing and validating options
    #   rubocop:disable Metrics/AbcSize
    #   rubocop:disable Metrics/MethodLength
    #   rubocop:disable Metrics/CyclomaticComplexity
    #   rubocop:disable Metrics/PerceivedComplexity
    # return [Hash] of the parsed options with dynamic defaults based on window size and kernel random
    def parsed_options(options)
      max_height = IO.console.winsize[0] - 2
      max_width  = IO.console.winsize[1]

      if options["delay"].to_i.negative?
        fail GameOfLife::Error, "Invalid --delay value. must be positive value (sadly this is how time works -so far-)"
      end
      if (options["width"]&.< 1) || (options["width"]&.> max_width)
        fail GameOfLife::Error, "Invalid --width value. " \
          "must be between 1 and #{max_width} (current terminal width)"
      end
      if (options["height"]&.< 1) || (options["height"]&.> max_height)
        fail GameOfLife::Error, "Invalid --height value. " \
          "must be between 1 and #{max_height} (current terminal height)"
      end

      fail GameOfLife::Error, "Invalid --live-cell value. must be a single character" if options["live-cell"].length > 1

      fail GameOfLife::Error, "Invalid --dead-cell value. must be a single character" if options["dead-cell"].length > 1

      if options["dead-cell"] == options["live-cell"]
        fail GameOfLife::Error, "Invalid --dead-cell value. must be a different character than --live-cell"
      end

      {
        # Defaults the hight to less then the height of the terminal to allow banner info
        # and an extra emtpy line to avoid triggering terminal scroll while flushing
        "height" => max_height,
        "width" => max_width,
        "seed" => rand(100_000),
      }.merge(options)
    end
    # rubocop:enable Metrics/AbcSize
    # rubocop:enable Metrics/MethodLength
    # rubocop:enable Metrics/CyclomaticComplexity
    # rubocop:enable Metrics/PerceivedComplexity

    # Generate a universe based on the options (from an input file/URI) or using a random/passed seed
    # This method also sets the Classes/Modules constants for the run defining the options for
    # Cell (Dead/Live) state representations, and the delay introduced after rendering each universe/generation
    # and the banner for the info/options
    #
    # @param options [Hash] CLI options to generate initial conditions
    # @option options [String | nil] "input" path to a local file or URL to open
    # @option options [Numeric] "seed" number to use if no "input" is provided (defaults to random)
    # @option options [Numeric] "width" of the universe
    # @option options [Numeric] "height" of the universe
    # @option options [Numeric] "delay" introduced after each cycle
    # @option options [String] "live-cell" representation
    # @option options [String] "dead-cell" representation
    # @return [GameOfLife::Universe] populated with the parsed input/seed
    def generate(options)
      # Define the constants for Cell representation
      GameOfLife::Cell.const_set(:LIVE_CELL, options["live-cell"])
      GameOfLife::Cell.const_set(:DEAD_CELL, options["dead-cell"])
      # Define the constant used for delay
      GameOfLife.const_set(:DELAY, options["delay"].to_f)

      banner = options["input"] ? "Input: #{options['input']}" : "Seed: #{options['seed']}"
      # Define the constant used for banner info
      GameOfLife.const_set(:BANNER, banner)

      return GameOfLife::Generators::Input.new(options) if options["input"]
      GameOfLife::Generators::Seed.new(options)
    end

    # The infinite running loop for rendering the current state of a universe
    # and then evolving it and repeating.
    def run(universe)
      Kernel.loop do
        GameOfLife.render(universe)
        universe.evolve!
      end
    end

    # render method to do the terminal screen/scrollback buffer cleaning
    # and rendering of the current universe/banner info
    # based on the delay/cell configured by the user
    def render(universe)
      puts "\33c\e[3J" # Clears the screen and scrollback buffer.
      puts universe.to_s
      puts BANNER
      sleep(DELAY / 1000.0)
    end
  end

  # Generators module for generating the universe initial state
  module Generators
    autoload :Input, "game_of_life/generators/input.rb"
    autoload :Seed,  "game_of_life/generators/seed.rb"
  end
end