diff --git a/Gemfile b/Gemfile index 617453e..08fa74a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,4 @@ -gem sinatra -gem sinatra-cross_origin -gem yard-sinatra +source "https://rubygems.org" + +gem "async" +gem "async-websocket" diff --git a/bin/server b/bin/server new file mode 100755 index 0000000..dc736e5 --- /dev/null +++ b/bin/server @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/environment" + +GameServer::App.start diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..9ffcdd0 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,7 @@ +require "bundler/setup" +Bundler.require + +require_relative "settings" +require_relative "logging" + +Dir[File.join(__dir__, "../lib/**/*.rb")].sort.each { |f| require f } diff --git a/config/logging.rb b/config/logging.rb new file mode 100644 index 0000000..b183ace --- /dev/null +++ b/config/logging.rb @@ -0,0 +1,3 @@ +require_relative "../lib/logging/logger" + +GameServer::Logging.setup diff --git a/config/settings.rb b/config/settings.rb new file mode 100644 index 0000000..56d1382 --- /dev/null +++ b/config/settings.rb @@ -0,0 +1,5 @@ +module Settings + PORT = 8080 + GAME_SPEED = 1.0 + SIMULATION_STEP = 0.05 +end diff --git a/core/.rspec b/core/.rspec deleted file mode 100644 index c99d2e7..0000000 --- a/core/.rspec +++ /dev/null @@ -1 +0,0 @@ ---require spec_helper diff --git a/core/Rakefile b/core/Rakefile deleted file mode 100644 index f972d99..0000000 --- a/core/Rakefile +++ /dev/null @@ -1,34 +0,0 @@ -# Nathan Hinton 2 Oct 2025 -# Rake file - - -desc 'Run RSpec tests' -task :test do - begin - require 'rspec/core/rake_task' - RSpec::Core::RakeTask.new(:spec) - Rake::Task['spec'].invoke - rescue LoadError - puts 'Failed to load RSpec to run tests... Try installing rspec' - end -end - - -desc 'Run the server for clients to connect to' -task :serve do - raise NotImplementedError.new('Server task not yet implimented!') -end - - -namespace :doc do - desc 'Create the YARD documentation for the project' - task :yard do - res = sh 'yard doc --plugin yard-sinatra *.rb' - sh 'yard stats --plugin yard-sinatra *.rb --list-undoc' - end - - desc 'Create code coverage' - task :coverage do - Rake::Task[:test].invoke - end -end diff --git a/core/building.rb b/core/building.rb deleted file mode 100644 index e3924bb..0000000 --- a/core/building.rb +++ /dev/null @@ -1,47 +0,0 @@ -# Nathan Hinton 19 Dec 2025 - - -require './space_object' - - -# This class is for buildings that are built on a planet. Currently it -# inherrits the SpaceObject class however it should probably inherrit a class -# that is for planets. -class Building] List of planets captured by that player - def get_captured_planets(id) - player = check_id(id) - result = [] - @planets.each do |planet| - result.append(planet) if planet.owner == player - end - return result - end - - # Returns a string representation of the map - # Begin no coverage - # :nocov: - def to_s() - # Create char map: - game_map = [] - @height.times do - tmp = [] - @width.times do - tmp.append('') - end - game_map.append(tmp) - end - - # Add planets to the map: - @planets.each do |planet| - pos_x, pos_y, pos_z = planet.position - puts("x: #{pos_x}, y: #{pos_y}") - game_map[pos_y][pos_x] += 'p' - end - - require 'pp' - pp(game_map) - game_map.each do |row| - row.each do |col| - if col == '' - print(' ') - else - print(col) - end - end - print("\n") - end - end - # :nocov: - # End no coverage - -end - -# General game errors -class GameError '*', # Allow all domains (you can change '*' to a specific domain for security) - 'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS', # Allow only specific methods - 'Access-Control-Allow-Headers' => 'Content-Type, Authorization' -end - - - -test = Game.new(width: 10, height: 10, planets: 20, name: 'TEST') -test.add_player(Player.new(name: 'hi')) -games = {'TEST' => test} - - -# Get the server status -# -# @return [String] UP -get '/status' do - return 'UP' -end - -# Creates a new game -# -# @param name [String] Required. The name of the game -# @param width [Integer] The width of the game space -# @param height [Integer] The height of the game space -# @param planets [Integer] The number of planets to have in the game -get '/create_game' do - puts "Creating game named #{params['name']}" - name = params['name'] - width = params['width'] || 10 - width = width.to_i - height = params['height'] || 10 - height = height.to_i - planets = params['planets'] || 20 - planets = planets.to_i - games.update({name => Game.new(width: width, height: height, planets: planets, name: name)}) - return 'OK' -end - - -# Gets games available -# -# @param None -# @return [Array] list of game names -get '/list_games' do - return games.keys.to_json -end - - -# old: # Returns true if the given player is part of the game -# old: # -# old: # @params game [String] Goes in the splat -# old: # @params player_name [String] The name of the player to validate -# old: # @return [Boolean] -# old: get '/game/*/players' do -# old: game = validate_game(params['splat'][0]) -# old: if game -# old: {"status" => game.find_player(params['player_name'])}.to_json -# old: end -# old: return {"status" => false}.to_json -# old: end - - -# Returns a player id for a given game -# -# @params game [String] Goes in the splat -# @params player_name [String] The name of the player to add -# @return [String] The ID for that player -get '/game/*/player_id' do - game = validate_game(params['splat'][0]) - if game - player = game.find_player(params['player_name']) - if player - return {"status" => player.get_id}.to_json - end - end - return {"status" => false}.to_json -end - - -# Returns data about how the game is set up - -# Returns the planets owned by a player -# -# @params game [String] Goes in the splat -# @params id [String] URL argument for player id. -get '/game/*/get_captured_planets' do - game = validate_game(params['splat'][0]) - if game - begin - return {'planets' => game.get_captured_planets(params['id'])}.to_json - end - end - return {"status" => false}.to_json -end - - -# Validates that a game exists: -# -# @param key [String] The name of the game to check for -# @return [Game] The game the key belongs to. (Or nil if not found) -def validate_game(name) - return games[name] -end diff --git a/core/planet.rb b/core/planet.rb deleted file mode 100644 index 39358ba..0000000 --- a/core/planet.rb +++ /dev/null @@ -1,63 +0,0 @@ -# Nathan Hinton 19 Dec 2025 -# Holds a planet and planet information - -require './space_object' - -# Class for planets. -class Planet < SpaceObject - attr_reader :owner, :resources - - # Create a planet in the game - def initialize() - super() - @buildings = [] - @owner = nil - @resources = { - 'food' => 0, - 'energy' => 0, - 'people' => 10 - } - - end - - # Any clean up that the planet will need - def delete() - end - - # Build a building on the planet - # - # @param building [Building] The building to be built - def building_build(building) - @buildings.append(building) - end - - # Remove building from the planet - # - # @param building [Building] The building to be destroyed - def building_destroy(building) - index = @buildings.index(building) - if index == nil - raise PlanetError, 'Building not found to delete' - end - @buildings.delete_at(index) - end - - # Capture a planet for a player - # - # @param player [Player] The player who is capturing the planet - def capture(player) - @buildings = [] # Destroy all the buildings on the planet - @owner = player - end - - # Tick for the planet - def tick() - @buildings.each do |building| - building.tick - end - end -end - -# Generic error for plannets -class PlanetError < StandardError -end diff --git a/core/player.rb b/core/player.rb deleted file mode 100644 index 4545477..0000000 --- a/core/player.rb +++ /dev/null @@ -1,43 +0,0 @@ -# Nathan Hinton 19 Dec 2025 - - -require 'securerandom' - -# This defines the player class -class Player - attr_reader :name, :resources, :id - - attr_accessor :home_planet - - # initialize a player. - # - # @param name [String] The name of the player - def initialize(name: 'test player') - @name = name - @planets = [] - @fleets = [] - @tech = [] - @resources = { - 'metal' => 0, - 'crystals' => 0, - 'credits' => 0 - } - # This id will be used to validate the player in the game - @id = SecureRandom.hex() - @id_retrieved = false - - @home_planet = nil - end - - # Returns the player ID. This *should* only be called once. When called again - # it should print a warning to the log. - # - # @return [String] The ID of the player - def get_id - if @id_retrieved - # Print a warning message! - end - @id_retrieved = true - return @id - end -end diff --git a/core/readme.md b/core/readme.md deleted file mode 100644 index c544ffb..0000000 --- a/core/readme.md +++ /dev/null @@ -1,6 +0,0 @@ -# Core of game - -This will have the classes and a base loop however it will not do anything more -than just store stuff I think. - -For now only the x and y cords are used. diff --git a/core/server.rb b/core/server.rb deleted file mode 100644 index 4d9a998..0000000 --- a/core/server.rb +++ /dev/null @@ -1,175 +0,0 @@ -# Nathan Hinton 20 Dec 2025 -# The main game server. This will handle the clients and the game in one go - -require 'async' -require 'async/websocket' -require 'json' -require 'yaml' - -require './game' - -class GameServer - def initialize(conf_path) - @clients = {} - File.open(conf_path, 'r') do |fi| - world_config = YAML.safe_load(fi.read) - end - puts world_config - @world = World.new( - width: world_config['width'], - height: world_config['height'], - planets: world_config['planets'], - game_id: world_config['game_id'], - name: world_config['name'] - ) - end - - def start(endpoint) - Async do |task| - Async::WebSocket::Server.open(endpoint) do |connection| - task.async do - handle_connection(connection) - end - end - task.async {simulation_loop} - end - end - - def handle_connection(ws) - player = nil - while message == ws.read - data = JSON.parse(message) - response = nil - - case data['type'] - when 'hello' - player = handle_hello(ws, data) - when 'querey' - response = handle_query(ws, data) - when 'command' - response = handle_command(ws, data) - else - response = send_error('UNKNOWN_TYPE', 'Unknown message type') - end - - ws.write(JSON.dump(response)) if response - end - rescue => e - puts "Connection error: #{e.message}" - ensure - disconnect(player) - end - - def handle_hello(ws, data) - token = data.dig("payload", "auth_token") - player = authenticate(token) - - @clients[player.id] = ws - - ws.write( - JSON.dump( - { - type: "update", - domain: "all", - payload: { - player_id: player_id, - server_time: @world.time, - game_speed: @world.game_speed - player: player.snapshot - } - } - ) - ) - - return player - end - - def handle_query(player, data) - domain = data["domain"] - - payload = - case domain - when "player" - player.snapshot - when "ship" - ship_id = data.dig("payload", "ship_id") - @world.ship(ship_id)&.snapshot - else - return error("UNKNOWN_DOMAIN", "Unknown query domain") - end - - { - type: "update", - domain: domain, - payload: payload - } - end - - def handle_command(player, data) - domain = data["domain"] - payload = data["payload"] - - case domain - when "ship" - validate_and_queue_ship_command(player, payload) - else - return error("UNKNOWN_DOMAIN", "Unknown command domain") - end - - { - type: "update", - domain: "command", - payload: { status: "accepted" } - } - rescue ValidationError => e - error("INVALID_COMMAND", e.message) - end - - def validate_and_queue_ship_command(player, payload) - ship = @world.ship(payload["ship_id"]) - raise ValidationError, "Ship not found" unless ship - raise ValidationError, "Not your ship" unless ship.owner == player - - ship.set_target(payload["target"]) - end - - def broadcast_event(type, domain, payload) - message = JSON.dump({ - type: type, - domain: domain, - payload: payload - }) - - @clients.values.each do |ws| - ws.write(message) - end - end - - - def simulation_loop - last = Time.now - - loop do - now = Time.now - delta_real = now - last - delta_sim = delta_real * @world.game_speed - - @world.advance(delta_sim) - - last = now - sleep 0.05 # 20 tps - end - end - - def error(code, message) - { - type: "error", - domain: "system", - payload: { - code: code, - message: message - } - } - end - -end diff --git a/core/space_object.rb b/core/space_object.rb deleted file mode 100644 index 5f08ba8..0000000 --- a/core/space_object.rb +++ /dev/null @@ -1,47 +0,0 @@ -# Nathan Hinton 19 Dec 2025 - - -require 'yaml' - -# Base class for any object in space. This can be a planet, asteroid, ship etc. -class SpaceObject - attr_reader :position - - File.open('./config.yaml', 'r') do |fi| - @@config = YAML.safe_load(fi.read()) - end - - # Create the object - # - def initialize() - @position = [0, 0, 0] - end - - # Delete the object. This should be overwritten by the sub objects. For - # example when an asteroid is destroyed it might want to explode and damage - # anything nearby first before being removed from the game. This is the path - # that will be used for that. - def delete() - end - - # Move the object to a new position. Not everything should be movable so this - # can be disallowed by sub classes. - # - # @param position [Array] The position that the object should be - # moved to. - def move(position) - @position = position - end - - # This *should* be overridden by any object that wants to do anyting. For - # example a ship probaly would like to move when the game tick happens. - def tick() - end - - # Define a method to get the config - # - # @return [Hash] A hash containing the configuration - def get_config() - return @@config - end -end diff --git a/core/spec/building_spec.rb b/core/spec/building_spec.rb deleted file mode 100644 index d88cbe0..0000000 --- a/core/spec/building_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -# Nathan Hinton 19 Dec 2025 - -# Defines tests for the buildings - - -require './building' -require './player' -require './planet' -require './space_object' - -RSpec.describe Building do - describe '#initialize' do - it 'Initializes to defaults' do - building = Building.new(Player.new(), Planet.new()) - expect(building.name).to eq 'default name' - expect(building.type).to eq 'Central Post' - end - - it 'initializes with custom values' do - building = Building.new(Player.new(), Planet.new(), name: 'nathan house', type: 'Solar Farm') - expect(building.name).to eq 'nathan house' - expect(building.type).to eq 'Solar Farm' - end - - it 'will raise error when invalid building type' do - expect{Building.new(Player.new(), Planet.new(), name: 'error', type: 'error')}.to raise_error BuildingError, "Invalid building type" - end - end - - describe '#tick' do - it 'Adds resources to the player' do - player = Player.new() - planet = Planet.new() - type = 'Central Post' - building = Building.new(player, planet, type: type) - config = SpaceObject.new().get_config - metal = config['buildings'][type]['produces']['metal'] - credits = config['buildings'][type]['produces']['credits'] - crystals = config['buildings'][type]['produces']['crystals'] - expect(player.resources).to eq({'metal' => 0, 'crystals' => 0, 'credits' => 0}) - expect{building.tick}.to_not raise_error - expect(player.resources).to eq({ - 'metal' => metal, - 'crystals' => crystals, - 'credits' => credits - }) - expect(planet.resources).to eq({ - 'food' => 0, - 'energy' => 0, - 'people' => 10 - }) - end - - it 'Adds resources to the planet' do - player = Player.new() - planet = Planet.new() - type = 'Nuclear Plant' - building = Building.new(player, planet, type: type) - config = SpaceObject.new().get_config - expect(player.resources).to eq({'metal' => 0, 'crystals' => 0, 'credits' => 0}) - expect{building.tick}.to_not raise_error - expect(player.resources).to eq({ - 'metal' => 0, - 'crystals' => 0, - 'credits' => 0 - }) - expect(planet.resources).to eq({ - 'food' => 0, - 'energy' => 1000, - 'people' => 10 - }) - end - end -end diff --git a/core/spec/examples.txt b/core/spec/examples.txt deleted file mode 100644 index dcee7b6..0000000 --- a/core/spec/examples.txt +++ /dev/null @@ -1,36 +0,0 @@ -example_id | status | run_time | ----------------------------------- | ------ | --------------- | -./spec/building_spec.rb[1:1:1] | passed | 0.00009 seconds | -./spec/building_spec.rb[1:1:2] | passed | 0.00008 seconds | -./spec/building_spec.rb[1:1:3] | passed | 0.00015 seconds | -./spec/building_spec.rb[1:2:1] | passed | 0.00012 seconds | -./spec/building_spec.rb[1:2:2] | passed | 0.00013 seconds | -./spec/game_spec.rb[1:1:1] | passed | 0.00006 seconds | -./spec/game_spec.rb[1:1:2] | passed | 0.00006 seconds | -./spec/game_spec.rb[1:1:3] | passed | 0.00006 seconds | -./spec/game_spec.rb[1:1:4] | passed | 0.00008 seconds | -./spec/game_spec.rb[1:2:1] | passed | 0.00022 seconds | -./spec/game_spec.rb[1:2:2] | passed | 0.00012 seconds | -./spec/game_spec.rb[1:3:1] | passed | 0.00008 seconds | -./spec/game_spec.rb[1:3:2] | passed | 0.00011 seconds | -./spec/game_spec.rb[1:4:1] | passed | 0.00007 seconds | -./spec/game_spec.rb[1:4:2] | passed | 0.00009 seconds | -./spec/game_spec.rb[1:5:1] | passed | 0.00008 seconds | -./spec/game_spec.rb[1:6:1] | passed | 0.00006 seconds | -./spec/game_spec.rb[1:6:2] | passed | 0.0001 seconds | -./spec/planet_spec.rb[1:1:1] | passed | 0.00005 seconds | -./spec/planet_spec.rb[1:1:2] | passed | 0.00006 seconds | -./spec/planet_spec.rb[1:3:1] | passed | 0.00014 seconds | -./spec/planet_spec.rb[1:4:1] | passed | 0.00009 seconds | -./spec/planet_spec.rb[1:4:2] | passed | 0.00082 seconds | -./spec/planet_spec.rb[1:5:1] | passed | 0.00007 seconds | -./spec/planet_spec.rb[1:5:2] | passed | 0.00036 seconds | -./spec/planet_spec.rb[1:6:1] | passed | 0.00446 seconds | -./spec/player_spec.rb[1:1:1] | passed | 0.0004 seconds | -./spec/player_spec.rb[1:2:1] | passed | 0.00242 seconds | -./spec/player_spec.rb[1:2:2] | passed | 0.00028 seconds | -./spec/simple_game_spec.rb[1:1] | passed | 0.00956 seconds | -./spec/space_object_spec.rb[1:1:1] | passed | 0.00006 seconds | -./spec/space_object_spec.rb[1:3:1] | passed | 0.00008 seconds | -./spec/space_object_spec.rb[1:4:1] | passed | 0.00006 seconds | -./spec/space_object_spec.rb[1:6:1] | passed | 0.00007 seconds | diff --git a/core/spec/game_spec.rb b/core/spec/game_spec.rb deleted file mode 100644 index 288a0c1..0000000 --- a/core/spec/game_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -# Nathan Hinton 19 Dec 2025 - - -require './game' -require './player' - -RSpec.describe Game do - describe '#initialize' do - it 'Creates an empty list of players' do - expect(Game.new().players).to eq [] - end - - it 'sets the width when passed in' do - expect(Game.new(width: 10).width).to eq 10 - end - - it 'sets the height when passed in' do - expect(Game.new(height: 11).height).to eq 11 - end - - it 'creates the specified number of planets' do - expect(Game.new(planets: 5).planets.length).to eq 5 - end - end - - - describe '#add_player' do - it 'adds a player to the game' do - game = Game.new(planets: 1) - player = Player.new() - game.add_player(player) - expect(game.players).to eq [player] - end - - it 'fails when there are no planets available for the player' do - game = Game.new(planets: 0) - player = Player.new() - expect{game.add_player(player)}.to raise_error GameError, "No available planets to add player to" - expect(game.players).to eq [] - end - end - - - describe '#remove_player' do - it 'removes a player from the game' do - game = Game.new(planets: 1) - player = Player.new() - game.add_player(player) - expect(game.players).to eq [player] - expect(game.remove_player(player)).to eq player - expect(game.players).to eq [] - end - - it 'returns an error when the player is not in the game' do - game = Game.new(planets: 1) - player = Player.new() - game.add_player(player) - player2 = Player.new() - expect{game.remove_player(player2)}.to raise_error GameError, 'Player not found to remove!' - end - end - - describe '#check_id' do - it 'returns true when the player id is valid for a player' do - game = Game.new(planets: 1) - player = Player.new() - game.add_player(player) - expect(game.check_id(player.get_id)).to eq player - end - - it 'returns false when the player id does not match a player' do - game = Game.new(planets: 1) - player = Player.new() - game.add_player(player) - expect{game.check_id('a')}.to raise_error GameError, 'Invalid player id!' - end - end - - describe '#get_captured_planets' do - it 'returns the captured planets of the player' do - game = Game.new(planets: 1) - player = Player.new() - game.add_player(player) - expect(game.get_captured_planets(player.get_id)).to eq game.instance_variable_get(:@planets) - end - end - - describe '#find_player' do - it 'returns false if the player is not in the game' do - game = Game.new() - expect(game.find_player('bill')).to eq false - end - - it 'returns true if the player is in the game' do - game = Game.new(planets: 1) - game.add_player(Player.new(name: 'bob')) - expect(game.find_player('bob')).to eq true - end - end -end diff --git a/core/spec/planet_spec.rb b/core/spec/planet_spec.rb deleted file mode 100644 index 3a0afde..0000000 --- a/core/spec/planet_spec.rb +++ /dev/null @@ -1,95 +0,0 @@ -# Nathan Hinton 19 Dec 2025 -# -# Defines tests for the planets - - -require './planet' -require './building' - -RSpec.describe Planet do - describe '#initialize' do - it 'Starts in a position' do - expect(Planet.new().instance_variable_get(:@position)).to eq [0, 0, 0] - end - - it 'Creates a buildings list' do - expect(Planet.new().instance_variable_get(:@buildings)).to eq [] - end - end - - - describe '#delete' do - end - - - describe '#building_build' do - it 'Adds a building to the planet building list' do - planet = Planet.new() - test_building = Building.new(Player.new(), planet) - expect{planet.building_build(test_building)}.to_not raise_error - expect(planet.instance_variable_get(:@buildings)).to eq [test_building] - end - end - - - describe '#building_destroy' do - it 'removes a building from the list' do - planet = Planet.new() - test_building = Building.new(Player.new(), planet) - planet.building_build(test_building) - expect(planet.instance_variable_get(:@buildings)).to eq [test_building] - expect(planet.building_destroy(test_building)).to eq test_building - expect(planet.instance_variable_get(:@buildings)).to eq [] - end - - it 'raises an error if the building can not be removed' do - planet = Planet.new() - test_building = Building.new(Player.new(), planet) - planet.building_build(test_building) - test_building2 = Building.new(Player.new(), planet) - expect{planet.building_destroy(test_building2)}.to raise_error PlanetError, 'Building not found to delete' - end - end - - - describe '#capture' do - it 'sets the planet owner' do - planet = Planet.new() - player = Player.new(name: 'test0') - expect(planet.capture(player)).to be player - expect(planet.instance_variable_get(:@owner)).to be player - end - - it 'removes all buildings from the planet' do - planet = Planet.new() - player = Player.new(name: 'test0') - planet.capture(player) - test_building = Building.new(player, planet) - planet.building_build(test_building) - - player1 = Player.new(name: 'test1') - expect(planet.instance_variable_get(:@buildings)).to eq [test_building] - expect(planet.capture(player1)).to be player1 - expect(planet.instance_variable_get(:@owner)).to be player1 - expect(planet.instance_variable_get(:@buildings)).to eq [] - end - end - - - describe '#tick' do - it 'Sends the tick event to all buildings on the planet' do - planet = Planet.new() - test_building1 = Building.new(Player.new(), planet) - test_building2 = Building.new(Player.new(), planet) - test_building3 = Building.new(Player.new(), planet) - planet.building_build(test_building1) - planet.building_build(test_building2) - planet.building_build(test_building3) - - expect(test_building1).to receive(:tick) - expect(test_building2).to receive(:tick) - expect(test_building3).to receive(:tick) - expect(planet.tick).to eq planet.instance_variable_get(:@buildings) - end - end -end diff --git a/core/spec/player_spec.rb b/core/spec/player_spec.rb deleted file mode 100644 index fdc8310..0000000 --- a/core/spec/player_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -# Nathan Hinton 19 Dec 2025 -# Test file for the player class. - - -require 'securerandom' - -require './player' - -RSpec.describe Player do - # Set the testing id - let(:id) { 'cfeab1a60864fd529cb9ce993e9c3af4' } - - before(:each) do - # Make sure that we get the same ID every time. - allow(SecureRandom).to receive(:hex).and_return(id) - end - - describe '#initialize' do - it 'initializes with name' do - expect(Player.new(name: 'Nathan').name).to eq 'Nathan' - end - end - - describe '#get_id' do - it 'returns the id' do - player = Player.new() - expect(player.instance_variable_get(:@id_retrieved)).to be false - expect(player.get_id).to eq id - expect(player.instance_variable_get(:@id_retrieved)).to be true - end - - it 'prints a warning when the id is gotten more than once' do - player = Player.new() - player.get_id - expect(player.instance_variable_get(:@id_retrieved)).to be true - expect(player.get_id).to eq id - expect(player.instance_variable_get(:@id_retrieved)).to be true - end - end - - describe '#planets' do - end - - - describe '#fleets', pending: 'Ships not yet implimented' do - end - - - describe '#tech', pending: 'Tech not yet implimented' do - end -end diff --git a/core/spec/simple_game_spec.rb b/core/spec/simple_game_spec.rb deleted file mode 100644 index c53361a..0000000 --- a/core/spec/simple_game_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -# Nathan Hinton 19 Dec 2025 - - -require './player' -require './game' - - -# This tests a simple game. -RSpec.describe 'Simple game', :sim => true do - it 'Initalize a new game and add a player' do - # Setup the player - player = Player.new() - id = player.get_id - expect(player.resources).to eq({'metal' => 0, 'crystals' => 0, 'galaxy credits' => 0}) - - # Setup the start planet - game = Game.new(width: 5, height: 5, planets: 5) - expect(game.planets.length).to eq 5 - game.add_player(player) - expect(game.get_captured_planets(id).length).to eq 1 - puts(game) - end -end diff --git a/core/spec/space_object_spec.rb b/core/spec/space_object_spec.rb deleted file mode 100644 index f2b82f2..0000000 --- a/core/spec/space_object_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -# Nathan Hinton 19 Dec 2025 -# -# Defines tests for the space object class - - -require './space_object' - -RSpec.describe SpaceObject do - describe '#initialize' do - it 'works with no args' do - expect(SpaceObject.new().instance_variable_get(:@position)).to eq [0, 0, 0] - end - end - - - describe '#delete' do - end - - - describe '#move' do - let(:so) { SpaceObject.new() } - it 'changes the objects position and returns the new position' do - expect(so.instance_variable_get(:@position)).to eq [0, 0, 0] - expect(so.move([2, 3, 4])).to eq [2, 3, 4] - expect(so.instance_variable_get(:@position)).to eq [2, 3, 4] - end - end - - - describe '#position' do - let(:so) { SpaceObject.new() } - it 'returns the current position' do - so.move([1, 2, 3]) - expect(so.position).to eq [1, 2, 3] - end - end - - - describe '#tick' do - end - - describe '#get_config' do - it 'returns the correct keys' do - expect(SpaceObject.new().get_config.keys).to eq ['planets', 'buildings'] - end - end -end diff --git a/core/spec/spec_helper.rb b/core/spec/spec_helper.rb deleted file mode 100644 index 47ee133..0000000 --- a/core/spec/spec_helper.rb +++ /dev/null @@ -1,109 +0,0 @@ -require 'simplecov' - -SimpleCov.configure do - coverage_dir 'coverage' - - # Start SimpleCov - SimpleCov.start -end - - -# This file was generated by the `rspec --init` command. Conventionally, all -# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. -# The generated `.rspec` file contains `--require spec_helper` which will cause -# this file to always be loaded, without a need to explicitly require it in any -# files. -# -# Given that it is always loaded, you are encouraged to keep this file as -# light-weight as possible. Requiring heavyweight dependencies from this file -# will add to the boot time of your test suite on EVERY test run, even for an -# individual file that may not need all of that loaded. Instead, consider making -# a separate helper file that requires the additional dependencies and performs -# the additional setup, and require it from the spec files that actually need -# it. -# -# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration -RSpec.configure do |config| - # rspec-expectations config goes here. You can use an alternate - # assertion/expectation library such as wrong or the stdlib/minitest - # assertions if you prefer. - config.expect_with :rspec do |expectations| - # This option will default to `true` in RSpec 4. It makes the `description` - # and `failure_message` of custom matchers include text for helper methods - # defined using `chain`, e.g.: - # be_bigger_than(2).and_smaller_than(4).description - # # => "be bigger than 2 and smaller than 4" - # ...rather than: - # # => "be bigger than 2" - expectations.include_chain_clauses_in_custom_matcher_descriptions = true - end - - # rspec-mocks config goes here. You can use an alternate test double - # library (such as bogus or mocha) by changing the `mock_with` option here. - config.mock_with :rspec do |mocks| - # Prevents you from mocking or stubbing a method that does not exist on - # a real object. This is generally recommended, and will default to - # `true` in RSpec 4. - mocks.verify_partial_doubles = true - end - - # This option will default to `:apply_to_host_groups` in RSpec 4 (and will - # have no way to turn it off -- the option exists only for backwards - # compatibility in RSpec 3). It causes shared context metadata to be - # inherited by the metadata hash of host groups and examples, rather than - # triggering implicit auto-inclusion in groups with matching metadata. - config.shared_context_metadata_behavior = :apply_to_host_groups - - # The settings below are suggested to provide a good initial experience - # with RSpec, but feel free to customize to your heart's content. - # This allows you to limit a spec run to individual examples or groups - # you care about by tagging them with `:focus` metadata. When nothing - # is tagged with `:focus`, all examples get run. RSpec also provides - # aliases for `it`, `describe`, and `context` that include `:focus` - # metadata: `fit`, `fdescribe` and `fcontext`, respectively. - config.filter_run_when_matching :focus - - # Allows RSpec to persist some state between runs in order to support - # the `--only-failures` and `--next-failure` CLI options. We recommend - # you configure your source control system to ignore this file. - config.example_status_persistence_file_path = "spec/examples.txt" - - # Limits the available syntax to the non-monkey patched syntax that is - # recommended. For more details, see: - # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ - config.disable_monkey_patching! - - # This setting enables warnings. It's recommended, but in some cases may - # be too noisy due to issues in dependencies. - config.warnings = true - - # Many RSpec users commonly either run the entire suite or an individual - # file, and it's useful to allow more verbose output when running an - # individual spec file. - if config.files_to_run.one? - # Use the documentation formatter for detailed output, - # unless a formatter has already been configured - # (e.g. via a command-line flag). - config.default_formatter = "doc" - end - - # Print the 10 slowest examples and example groups at the - # end of the spec run, to help surface which specs are running - # particularly slow. - config.profile_examples = 10 - - # Run specs in random order to surface order dependencies. If you find an - # order dependency and want to debug it, you can fix the order by providing - # the seed, which is printed after each run. - # --seed 1234 - config.order = :random - - # Seed global randomization in this process using the `--seed` CLI option. - # Setting this allows you to use `--seed` to deterministically reproduce - # test failures related to randomization by passing the same `--seed` value - # as the one that triggered the failure. - Kernel.srand config.seed - - # Exclude by default the simulate tests. - config.filter_run_excluding :sim => true -end diff --git a/core/test.sh b/core/test.sh deleted file mode 100644 index dc659b1..0000000 --- a/core/test.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/bash - -rerun 'ruby server.rb' diff --git a/core/server.md b/lib/commands/player_commands.rb similarity index 100% rename from core/server.md rename to lib/commands/player_commands.rb diff --git a/lib/commands/ship_commands.rb b/lib/commands/ship_commands.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/domain/planet.rb b/lib/domain/planet.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/domain/player.rb b/lib/domain/player.rb new file mode 100644 index 0000000..1595e28 --- /dev/null +++ b/lib/domain/player.rb @@ -0,0 +1,11 @@ +module GameServer + module Domain + class Player + attr_reader :money + + def initialize + @money = 1000 + end + end + end +end diff --git a/lib/domain/ship.rb b/lib/domain/ship.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/events/build_event.rb b/lib/events/build_event.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/events/combat_event.rb b/lib/events/combat_event.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/events/event.rb b/lib/events/event.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/game_server.rb b/lib/game_server.rb new file mode 100644 index 0000000..d2ce0f8 --- /dev/null +++ b/lib/game_server.rb @@ -0,0 +1,8 @@ +module GameServer + class App + def self.start + world = Simulation::World.new + Server::WebSocketServer.new(world).start + end + end +end diff --git a/lib/logging/context.rb b/lib/logging/context.rb new file mode 100644 index 0000000..d17526f --- /dev/null +++ b/lib/logging/context.rb @@ -0,0 +1,18 @@ +module GameServer + module Logging + module Context + def self.with(data) + old = Thread.current[:log_ctx] + Thread.current[:log_ctx] = data + yield + ensure + Thread.current[:log_ctx] = old + end + + def self.current + ctx = Thread.current[:log_ctx] + ctx ? ctx.map { |k,v| "#{k}=#{v} " }.join : "" + end + end + end +end diff --git a/lib/logging/formatter.rb b/lib/logging/formatter.rb new file mode 100644 index 0000000..08ab0dd --- /dev/null +++ b/lib/logging/formatter.rb @@ -0,0 +1,11 @@ +require "time" + +module GameServer + module Logging + class Formatter + def call(severity, time, _, msg) + "[#{time.utc.iso8601}] #{severity} #{Context.current}#{msg}\n" + end + end + end +end diff --git a/lib/logging/logger.rb b/lib/logging/logger.rb new file mode 100644 index 0000000..e6806c6 --- /dev/null +++ b/lib/logging/logger.rb @@ -0,0 +1,19 @@ +require "logger" + +module GameServer + module Logging + def self.setup + FileUtils.mkdir_p("log") + file = File.open("log/development.log", "a") + file.sync = true + + @logger = Logger.new(file) + @logger.level = Logger::DEBUG + @logger.formatter = Formatter.new + end + + def self.logger + @logger + end + end +end diff --git a/lib/protocol/errors.rb b/lib/protocol/errors.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/protocol/message.rb b/lib/protocol/message.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/protocol/validator.rb b/lib/protocol/validator.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/queries/player_queries.rb b/lib/queries/player_queries.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/queries/ship_queries.rb b/lib/queries/ship_queries.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/server/connection.rb b/lib/server/connection.rb new file mode 100644 index 0000000..2784df4 --- /dev/null +++ b/lib/server/connection.rb @@ -0,0 +1,27 @@ +require "json" + +module GameServer + module Server + class Connection + def initialize(ws, world) + @ws = ws + @world = world + @router = MessageRouter.new(world) + end + + def run + Logging.logger.info "connection opened" + + while msg = @ws.read + data = JSON.parse(msg) + response = @router.handle(data) + @ws.write(JSON.dump(response)) if response + end + rescue => e + Logging.logger.error e.message + ensure + Logging.logger.info "connection closed" + end + end + end +end diff --git a/lib/server/message_router.rb b/lib/server/message_router.rb new file mode 100644 index 0000000..5a610e0 --- /dev/null +++ b/lib/server/message_router.rb @@ -0,0 +1,61 @@ +module GameServer + module Server + class MessageRouter + def initialize(world) + @world = world + end + + def handle(data) + case data["type"] + when "hello" + hello + when "query" + query(data) + when "command" + command(data) + else + error("UNKNOWN_TYPE") + end + end + + def hello + { + type: "update", + domain: "world", + payload: { + time: @world.time, + game_speed: Settings::GAME_SPEED + } + } + end + + def query(data) + if data["domain"] == "player" + { + type: "update", + domain: "player", + payload: { money: 1000 } + } + else + error("UNKNOWN_DOMAIN") + end + end + + def command(data) + { + type: "update", + domain: "command", + payload: { status: "accepted" } + } + end + + def error(code) + { + type: "error", + domain: "system", + payload: { code: code } + } + end + end + end +end diff --git a/lib/server/websocket_server.rb b/lib/server/websocket_server.rb new file mode 100644 index 0000000..7b6dda3 --- /dev/null +++ b/lib/server/websocket_server.rb @@ -0,0 +1,33 @@ +require "async" +require "async/websocket" + +module GameServer + module Server + class WebSocketServer + def initialize(world) + @world = world + end + + def start + Async do |task| + task.async { simulation_loop } + + Async::WebSocket::Server.open("0.0.0.0", Settings::PORT) do |ws| + task.async { Connection.new(ws, @world).run } + end + end + end + + def simulation_loop + last = Time.now + loop do + now = Time.now + delta = (now - last) * Settings::GAME_SPEED + @world.advance(delta) + last = now + sleep Settings::SIMULATION_STEP + end + end + end + end +end diff --git a/lib/simulation/clock.rb b/lib/simulation/clock.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/simulation/combat.rb b/lib/simulation/combat.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/simulation/economy.rb b/lib/simulation/economy.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/simulation/movement.rb b/lib/simulation/movement.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/simulation/tick_loop.rb b/lib/simulation/tick_loop.rb new file mode 100644 index 0000000..e69de29 diff --git a/lib/simulation/world.rb b/lib/simulation/world.rb new file mode 100644 index 0000000..8704002 --- /dev/null +++ b/lib/simulation/world.rb @@ -0,0 +1,15 @@ +module GameServer + module Simulation + class World + attr_reader :time + + def initialize + @time = 0.0 + end + + def advance(delta) + @time += delta + end + end + end +end diff --git a/lib/utils/math.rb b/lib/utils/math.rb new file mode 100644 index 0000000..e69de29 diff --git a/ui.md b/ui.md deleted file mode 100644 index c4aac9f..0000000 --- a/ui.md +++ /dev/null @@ -1,3 +0,0 @@ -# Designing UI - -I have decided to use godot to design the UI. (Hopefully it works...)