# frozen_string_literal: true # Space game server engine. # Coordinates game state, player connections, and world simulation. require 'game/player' require 'game/planet' require 'thread' require 'securerandom' # Main file for the Game module module Game # Controls how frequently autosaves are created (seconds) SAVE_INTERVAL = 15 # Controls how frequently updates are sent to the client about the player (seconds) PLAYER_UPDATE_INTERVAL = 2 # Controls how fast the game scales with player count. Bigger means more # players required to change the speed. GAME_SPEED_FACTOR = 1 ## # The central server object that keeps track of players, planets, and # game-time. # # @api public class Game # The name of the game attr_reader :name # Returns the width of the game space attr_reader :width # Returns the height of the game space attr_reader :height # Returns the depth of the game space attr_reader :depth # Collection of players in the game attr_reader :players # Create the game ## # Initialize the game from a configuration hash. The hash is expected # to be the result of parsing the YAML file `game_data/game_conf.yaml`. # # @param conf_data [Hash] The parsed configuration # @raise [ArgumentError] if the configuration is missing a required key # def initialize(conf_data) # Validate that the correct keys exist ['name', 'width', 'height', 'depth', 'players', 'planets', 'ships', 'time', 'player_defaults'].each do |key| if conf_data[key].nil? raise ArgumentError, "Missing toplevel key '#{key}' in configuration" end end @conf_data = conf_data @name = conf_data['name'] @width = conf_data['width'] @height = conf_data['height'] @depth = conf_data['depth'] @to_load_players = conf_data['players'] @to_load_planets = conf_data['planets'] @game_time = conf_data['time'] @player_defaults = @conf_data['player_defaults'] @connected_players = [] # When we start up we do not have any players connected. # Load the players: @players = [] @to_load_players.each do |player_id| @players.append(Player.load_from_save(player_id)) end # If we are an integer then we need to create new planets. @planets = [] if @to_load_planets.is_a? Integer puts "Appears to be the first time running. Creating #{@to_load_planets} planets" @to_load_planets.times do @planets.append(Planet.new(@width, @height, @depth)) end else @to_load_planets.length.times do |idx| planet = Planet.load_from_save(@to_load_planets[idx]) @planets.append(planet) end end # Load ships @ships = [] conf_data['ships'].each do require 'byebug'; debugger end end ## # Register a client connection. This method sccepts a TCP docket and a hash # that must comtain a `player_id`. If the player already exists in the game # it is reconnected otherwise a new `Player` is created and added to the # game if possible (There must be at least one free planet) # # @param client [TCPSocket] The client socket # @param command [Hash] JSON parsed command from the client. # @option command [String] :player_id Required, The player ID # @option command [String] :faction Required, The faction the player should join as. # # @return [Hash] status hash that will be converted to JSON before being # sent back to the client # def player_connect(client, command) player_id = command.dig('payload', 'player_id') faction = command.dig('payload', 'faction') raise ArgumentError, 'player_id missing in command' unless player_id raise ArgumentError, 'faction is missing in command' unless faction player = player_registered?(player_id) if player # Re-connect an existing but disconnected player if player.connected? raise GameError, 'Player already connected!' end player.connect(client) else # Create a brand new player and claim a planet for them player = create_player(player_id, faction) return player if player.is_a?(Hash) && player['type'] == 'error' # error returned player.connect(client) @players << player end { 'type' => 'update', 'payload' => 'success' } end ## # Create a new player from scratch. The new player is assigned a free # planet and receives the default starting resources. # # @param player_id [String] the unique identifier that will be stored # on the new player; if `nil` a random hex string will be generated # @param faction [String] The faction that the player belongs too. # # @return [Player, Hash] The new player object on success or an error # hash if no free planet is available. # def create_player(player_id, faction) free_planets = @planets.select { |p| p.owner.nil? } if free_planets.empty? return { 'type' => 'error', 'payload' => 'Unable to allocate a planet for a new player' } end home = free_planets.sample player = Player.new(player_id || SecureRandom.hex, faction) player.set_resources( @player_defaults['credits'], @player_defaults['metal'], @player_defaults['crystals'], @player_defaults['fuel'], ) player.claim_planet(home) player.make_home_planet(home) player end ## # The entry point for all messages that arrive from a client. The # payload must be a hash decoded from JSON and contain a `type` field. # # @param client [TCPSocket] The socket that the client used. # @param command [Hash] The parsed client command # # @return [String] JSON-encoded response # def parse_command(client, command) return error_response('Player not registered!') unless command['player_id'] case command['type'] when 'hello' parse_hello(client, command) when 'query' parse_query(command) when 'command' error_response('Command handling not implemented') else error_response('Unknown type') end end ## # Dispatch a `query` command to the appropriate helper. Only a small # subset of query types is required for the test-suite; all others # return a generic error. # # @param query [Hash] The query to be parsed # # @return [String] JSON-encoded response # def parse_query(query) return error_response('Missing query payload') unless query['payload'] player = player_registered?(query['player_id']) return error_response('Player not found') unless player case query['domain'] when 'player' case query['payload'] when 'update' return player.update(query['payload']['request_id']) else error_response('Unknown payload for player domain') end else error_response('Unknown domain') end end ## # Return a status hash that contains a friendly error message. The # helper exists only to keep the body of all error branches short. # # @param msg [String] The error message # @return [String] JSON-encoded error response # def error_response(msg) { 'type' => 'error', 'request_id' => -1, 'payload' => msg }.to_json end ## # Return an update response to the client def update_response(request_id, domain, payload) {'type' => 'update', 'domain' => domain, 'request_id' => request_id, 'payload' => payload}.to_json end # Return an event response to the client def event_response(request_id, domain, payload) {'type' => 'event', 'domain' => domain, 'request_id' => request_id, 'payload' => payload}.to_json end ## # Return a JSON object that tells a client about the current # server-time. Useful for debugging or “ping” operations. # # @return [String] JSON-encoded hash # def info(request_id) { 'type' => 'update', 'domain' => 'game', 'request_id' => request_id, 'payload' => { 'name' => @name, 'width' => @width, 'height' => @height, 'depth' => @depth, 'time' => @game_time } }.to_json end # Actually start running the game def run() # Main game loop last_time = Time.now # The delta time will be very small on the first one. next_save = @game_time + SAVE_INTERVAL next_client_update = @game_time + PLAYER_UPDATE_INTERVAL while true # This is the **ONLY** place where Time.now should be used delta_time = (Time.now - last_time) last_time = Time.now # Here is where we do our time scaling. Count the number of players in the game then use that mult player_count = @players.map {|player| player.connected?}.count true @game_speed = (player_count / GAME_SPEED_FACTOR) delta_time * (player_count / GAME_SPEED_FACTOR) @game_time += delta_time # Order here is important, We must update the buildings first (To # accrew resources for this delta) Because planets hold the buildings # they must be first. @planets.each do |planet| planet.update(delta_time) end @ships.each do |ship| ship.update(delta_time) end @players.each do |player| player.update(delta_time) end # Perform game saves (Or not for now...) if (@game_time) > next_save puts 'Trying to save...' next_save = @game_time + SAVE_INTERVAL save_game_state end # Send out regular updates to the clients (Every 2 seconds) if @game_time > next_client_update next_client_update = @game_time + PLAYER_UPDATE_INTERVAL @players.each do |player| if player.connected? player.send_update end end end if player_count == 0 puts 'No players connected, pausing time' while 0 == (@players.map {|player| player.connected?}.count true) sleep(1) end end #sleep(0.05) # About 20 TPS/FPS sleep(1) end end ## # Persist the current state of the game to the disk. The method is # fully unit-tested via a double that captures the YAML payload # instead of actually writing a file. # # @return [void] # def save_game_state config = { 'name' => @name, 'width' => @width, 'height' => @height, 'depth' => @depth, 'players' => @players.map(&:player_id), 'planets' => @planets.map(&:planet_id), 'ships' => @ships.map(&:ship_id), 'time' => @game_time, 'player_defaults' => @player_defaults } File.open('game_data/game_conf.yaml', 'w') { |f| f.write(YAML.dump(config)) } # Save the planets @planets.each do |planet| planet.save_to_file end # Save the ships @ships.each do |ship| ship.save_to_file end # Save the players @players.each do |player| player.save_to_file end end ## # Determine whether a player with a given id is known to the game. # # @param player_id [String] the id that the caller is looking for # # @return [Player, false] the `Player` instance if found or `false` # otherwise # def player_registered?(player_id) @players.find { |p| p.player_id == player_id } || false end # Loads players from a list of IDs. # @param ids [Array] list of player IDs. # @return [Array] the loaded players. def load_players(ids) ids.map { |id| Player.load_from_id(id) } end # Loads planets from a configuration list or creates new ones. # @param planets [Integer, Array] number of planets to create or list of planet IDs. # @return [Array] the loaded or created planets. def load_planets(planets) if planets.is_a?(Integer) # legacy config - integer means “create N” Array.new(planets) { Planet.new } else planets.map { |id| Planet.load_from_id(id) } end end # Loads ships from a list of IDs. # @param ids [Array] list of ship IDs. # @return [Array] the loaded ships. def load_ships(ids) ids.map { |id| Ship.load_from_id(id) } # Ship is a placeholder end end ## # Raised when an invalid operation is attempted (e.g. reconnecting a # player that already has a socket). The tests catch this error # to confirm that the server protects against accidental double # connections. class GameError < StandardError; end end