diff --git a/lib/engine/game/g_18_uruguay/game.rb b/lib/engine/game/g_18_uruguay/game.rb index 340a3f7b0d..506ff6141d 100644 --- a/lib/engine/game/g_18_uruguay/game.rb +++ b/lib/engine/game/g_18_uruguay/game.rb @@ -7,6 +7,10 @@ require_relative 'companies' require_relative 'trains' require_relative 'phases' +require_relative 'goods' +require_relative 'loans' +require_relative 'step/route_rptla' +require_relative '../../loan' module Engine module Game @@ -21,9 +25,11 @@ class Game < Game::Base include Companies include Trains include Phases + include Goods + include InterestOnLoans + include Loans EBUY_SELL_MORE_THAN_NEEDED = true - GOODS_TRAIN = 'Goods' register_colors(darkred: '#ff131a', red: '#d1232a', @@ -39,7 +45,6 @@ class Game < Game::Base SELL_BUY_ORDER = :sell_buy TILE_RESERVATION_BLOCKS_OTHERS = true CURRENCY_FORMAT_STR = '$U%d' - GOODS_DESCRIPTION_STR = 'Number of goods: ' MUST_BUY_TRAIN = :always @@ -53,6 +58,7 @@ class Game < Game::Base RPTLA_STARTING_PRICE = 50 RPTLA_STOCK_ROW = 11 NUMBER_OF_LOANS = 99 + LOAN_VALUE = 100 GAME_END_CHECK = { custom: :one_more_full_or_set }.freeze @@ -99,6 +105,8 @@ class Game < Game::Base 'GOODS_CATTLE10' => '/icons/1846/meat.svg', }.freeze + GOODS_TRAIN = 'Goods' + GOODS_DESCRIPTION_STR = 'Number of goods: ' PORTS = %w[E1 G1 I1 J4 K5 K7 K13].freeze MARKET = [ %w[70 75 80 90 100p 110 125 150 175 200 225 250 275 300 325 350 375 400 425 450], @@ -167,6 +175,7 @@ def cattle_farm def setup super + goods_setup @rptla = @corporations.find { |c| c.id == 'RPTLA' } @fce = @corporations.find { |c| c.id == 'FCE' } @@ -259,13 +268,16 @@ def operating_round(round_num) G18Uruguay::Step::CattleFarm, Engine::Step::SpecialTrack, Engine::Step::SpecialToken, + G18Uruguay::Step::TakeLoanBuyCompany, Engine::Step::HomeToken, Engine::Step::Track, - Engine::Step::Token, - Engine::Step::Route, + G18Uruguay::Step::Token, + G18Uruguay::Step::Route, + G18Uruguay::Step::RouteRptla, Engine::Step::Dividend, Engine::Step::DiscardTrain, Engine::Step::BuyTrain, + [G18Uruguay::Step::TakeLoanBuyCompany, { blocks: true }], ], round_num: round_num) end @@ -290,6 +302,112 @@ def abilities_ignore_owner(entity, type = nil, time: nil, on_phase: nil, passive active_abilities end + + # Nationalized + def nationalized? + @nationalized + end + + def operating_order + super.sort.partition { |c| c.type != :bank }.flatten + end + + # Loans + def float_corporation(corporation) + return if corporation == @rptla + return unless @loans + + amount = corporation.par_price.price * 5 + @bank.spend(amount, corporation) + @log << "#{corporation.name} receives #{format_currency(corporation.cash)}" + take_loan(corporation, @loans[0]) if @loans.size.positive? && !nationalized? + end + + def perform_ebuy_loans(entity, remaining) + ebuy = true + while remaining.positive? && entity.share_price.price != 0 + # if at max loans, company goes directly into receiverhsip + if @loans.empty? + @log << "There are no more loans available to force buy a train, #{entity.name} goes into receivership" + break + end + loan = @loans.first + take_loan(entity, loan, ebuy: ebuy) + remaining -= loan.amount + end + end + + # Goods + def number_of_goods_at_harbor + ability = @rptla.abilities.find { |a| a.type == 'Goods' } + ability.description[/\d+/].to_i + end + + def add_good_to_rptla + ability = @rptla.abilities.find { |a| a.type == 'Goods' } + count = number_of_goods_at_harbor + 1 + ability.description = GOODS_DESCRIPTION_STR + count.to_s + end + + def remove_goods_from_rptla(goods_count) + return if number_of_goods_at_harbor < goods_count + + ability = @rptla.abilities.find { |a| a.type == 'Goods' } + count = number_of_goods_at_harbor - goods_count + ability.description = GOODS_DESCRIPTION_STR + count.to_s + end + + def check_distance(route, visits, train = nil) + @round.current_routes[route.train] = route + if route.corporation != @rptla && !nationalized? + raise RouteTooLong, 'Need to have goods to run to port' unless check_for_port_if_goods_attached(route, + visits) + raise RouteTooLong, 'Goods needs to be shipped to port' unless check_for_goods_if_run_to_port(route, + visits) + end + raise RouteTooLong, '4D trains cannot deliver goods' if route.train.name == '4D' && visits_include_port?(visits) + + super + end + + # Revenue + def revenue_str(route) + return super unless route&.corporation == @rptla + + 'Ship' + end + + def routes_revenue(route, corporation) + revenue = super + return revenue if @rptla != corporation + + revenue += (corporation.loans.size.to_f / 2).floor * 10 + revenue + end + + def routes_subsidy(route, corporation) + return super if @rptla != corporation + + (corporation.loans.size.to_f / 2).ceil * 10 + end + + def revenue_for(route, stops) + revenue = super + revenue *= 2 if route.train.name == '4D' + revenue *= 2 if final_operating_round? + return revenue unless route&.corporation == @rptla + + train = route.train + revenue * goods_on_train(train) + end + + def or_round_finished + corps_pay_interest unless nationalized? + end + + def final_operating_round? + @final_turn == @turn + end end end end diff --git a/lib/engine/game/g_18_uruguay/goods.rb b/lib/engine/game/g_18_uruguay/goods.rb new file mode 100644 index 0000000000..d1704e3d61 --- /dev/null +++ b/lib/engine/game/g_18_uruguay/goods.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Engine + module Game + module G18Uruguay + module Goods + def goods_setup + @pickup_hex_for_train = {} + end + + def train_with_goods?(train) + return unless train + + @pickup_hex_for_train.key?(train.id) + end + + def attach_good_to_train(train, hex) + train.name += '+' + self.class::GOODS_TRAIN + '(' + hex.id + ')' if hex + @pickup_hex_for_train[train.id] = hex + end + + def good_pickup_hex(train) + @pickup_hex_for_train[train.id] + end + + def unload_good(train) + train.name = train.name.partition('+')[0] unless train.nil? + @pickup_hex_for_train.delete(train.id) if train_with_goods?(train) + end + + def visits_include_port?(visits) + visits.any? { |visit| self.class::PORTS.include?(visit.hex.id) } + end + + def route_include_port?(route) + route.hexes.any? { |hex| self.class::PORTS.include?(hex.id) } + end + + def check_for_goods_if_run_to_port(route, visits) + true if route.corporation == @rptla + visits_include_port?(visits) || !train_with_goods?(route.train) + end + + def check_for_port_if_goods_attached(route, visits) + true if route.corporation == @rptla + !visits_include_port?(visits) || train_with_goods?(route.train) + end + + def goods_on_train(train) + m = train.name.match(/.*\+.*(?\d+).*/) + return 0 if m.nil? + + m[:count]&.to_i + end + end + end + end +end diff --git a/lib/engine/game/g_18_uruguay/loans.rb b/lib/engine/game/g_18_uruguay/loans.rb new file mode 100644 index 0000000000..102ed48e33 --- /dev/null +++ b/lib/engine/game/g_18_uruguay/loans.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Engine + module Game + module G18Uruguay + module Loans + def init_loans + Array.new(self.class::NUMBER_OF_LOANS) { |id| Loan.new(id, self.class::LOAN_VALUE) } + end + + def maximum_loans(entity) + entity == @rptla ? self.class::NUMBER_OF_LOANS : entity.num_player_shares + end + + def loans_due_interest(entity) + entity.loans.size + end + + def interest_owed(entity) + return 0 if entity == @rptla + + 10 * loans_due_interest(entity) + end + + def interest_owed_for_loans(count) + 10 * count + end + + def can_take_loan?(entity, ebuy: nil) + # return false if nationalized? + return false if entity == @rlpta + return true if ebuy + + entity.corporation? && + entity.loans.size < maximum_loans(entity) && + !@loans.empty? + end + + def take_loan(entity, loan, ebuy: nil) + raise GameError, "Cannot take more than #{maximum_loans(entity)} loans" unless can_take_loan?(entity, ebuy: ebuy) + + # raise GameError, "Not allowed to take loans after nationalization" if @game.nationalized? + + @bank.spend(loan.amount, entity) + entity.loans << loan + @rptla.loans << loan.dup + @loans.delete(loan) + @log << "#{entity.name} takes a loan and receives #{format_currency(loan.amount)}" + end + + def payoff_loan(entity, number_of_loans, spender) + total_amount = 0 + number_of_loans.times do |_i| + paid_loan = entity.loans.pop + amount = paid_loan.amount + total_amount += amount + spender.spend(amount, @bank) + end + @log << "#{spender.name} payoff #{number_of_loans} loan(s) for #{entity.name} and pays #{total_amount}" + end + + def adjust_stock_market_loan_penalty(entity) + delta = entity.loans.size - maximum_loans(entity) + return unless delta.positive? + + delta.times do |_i| + @stock_market.move_left(entity) + end + end + + def take_loan_if_needed_for_interest!(entity) + owed = interest_owed(entity) + return if owed.zero? + + remaining = owed - entity.cash + perform_ebuy_loans(entity, remaining + 10) if remaining.positive? + end + + def corps_pay_interest + corps = @round.entities.select { |entity| entity.loans.size.positive? && entity != @rptla } + corps.each do |corp| + next if corp.closed? + + take_loan_if_needed_for_interest!(corp) + pay_interest!(corp) + end + end + end + end + end +end diff --git a/lib/engine/game/g_18_uruguay/step/route.rb b/lib/engine/game/g_18_uruguay/step/route.rb new file mode 100644 index 0000000000..d82a07e80d --- /dev/null +++ b/lib/engine/game/g_18_uruguay/step/route.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require_relative '../../../step/route' + +module Engine + module Game + module G18Uruguay + module Step + class Route < Engine::Step::Route + def setup + @round.current_routes = {} + end + + def actions(entity) + return %w[].freeze if entity.corporation == @game.rptla + return [] if !entity.operator? || @game.route_trains(entity).empty? || !@game.can_run_route?(entity) + return [] if entity.corporation? && entity.type == :minor + + actions = ACTIONS.dup + actions << 'choose' if choosing?(entity) + actions + end + + def choosing?(_entity) + true + end + + def choice_name + return 'Attach goods to ships' if current_entity == @game.rptla + + 'Attach good to a train' + end + + def goods_hexes + @game.hexes.select do |hex| + hex.assignments.keys.find { |a| a.include? 'GOODS' } + end + end + + def choices + choices = {} + goods_train_choices(current_entity).each_with_index do |train, _index| + hex = train['hex'] + index_str = "train\##{train['train_index']}" + index_str += "_#{train['hex'].id}" unless hex.nil? + choices[index_str] = "#{train['train'].name} train\##{train['train_index']} (#{train['hex'].id})" unless hex.nil? + choices[index_str] = "#{train['train'].name} train\##{train['train_index']} unload" if hex.nil? + end + choices + end + + def route_for_train(train) + @round.current_routes[train] unless train.nil? + end + + def get_train_goods_combo(name) + str_split = name.split('_') + train_index = str_split[0].split('#')[1] + train = @game.route_trains(current_entity)[train_index.to_i - 1] + hex = @game.hex_by_id(str_split[1]) if str_split.size > 1 + [train, hex] + end + + def goods_train_choices(entity) + choices_array = [] + @game.route_trains(entity).each_with_index do |train, index| + route = route_for_train(train) + if @game.train_with_goods?(train) + choices_array.push({ train: train, train_index: index + 1, hex: nil, loaded: true }) + else + goods_hexes.each do |hex| + if route + val = { train: train, train_index: index + 1, hex: hex, loaded: false } + choices_array.push(val) if route.hexes.include?(hex) + end + end + end + end + choices_array + end + + def process_choose(action) + entity = action.entity + + train, hex = get_train_goods_combo(action.choice) + + if hex + @log << "#{entity.id} attaches good from #{hex.id} to a #{train.name} train" + + @game.attach_good_to_train(train, hex) + else + @log << "#{entity.id} remove good from #{train.name} train" + @game.unload_good(train) + end + end + + def detach_goods(routes) + routes.each do |route| + train = route.train + next unless @game.train_with_goods?(train) + + hex = @game.good_pickup_hex(train) + good = hex.assignments.keys.find { |a| a.include? 'GOODS' } + @game.unload_good(train) + raise NoToken, "No good token found at Hex #{hex&.id}" if good.nil? + raise NoToken, "Hex #{hex&.id} is not included in route for train #{train.name}" unless route.hexes.include?(hex) + + hex.remove_assignment!(good) + @log << "#{current_entity.id} moves a good to the harbor" + @game.add_good_to_rptla unless good.nil? + end + end + + def process_run_routes(action) + super + entity = action.entity + detach_goods(action.routes) unless action.entity == @game.rptla + @game.route_trains(entity)&.each do |train| + @game.unload_good(train) + end + end + + def round_state + super.merge({ current_routes: {} }) + end + end + end + end + end +end diff --git a/lib/engine/game/g_18_uruguay/step/route_rptla.rb b/lib/engine/game/g_18_uruguay/step/route_rptla.rb new file mode 100644 index 0000000000..ecec1b1d29 --- /dev/null +++ b/lib/engine/game/g_18_uruguay/step/route_rptla.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative '../../../step/route' + +module Engine + module Game + module G18Uruguay + module Step + class RouteRptla < Engine::Step::Route + def setup + @goods_shipped = 0 + end + + def actions(entity) + return [] unless entity.corporation == @game.rptla + + %w[run_routes choose].freeze + end + + def choosing?(_entity) + true + end + + def choice_name + 'Attach goods to ships' + end + + def choices + choices = {} + number_of_goods = [@game.number_of_goods_at_harbor, total_ship_capacity?(current_entity)].min + number_of_goods.times do |count| + choices[count] = '1 Good' if count.zero? + choices[count] = (count + 1).to_s + ' Goods' if count.positive? + end + choices + end + + def ship_capacity(train) + (train.name.scan(/\d/)[0].to_f / 2).ceil + end + + def total_ship_capacity?(entity) + trains = @game.route_trains(entity) + total_capacity = 0 + trains.each do |train| + total_capacity += ship_capacity(train) + end + total_capacity + end + + def process_choose(action) + entity = action.entity + goods_to_deliver = action.choice.to_i + 1 + trains = @game.route_trains(entity) + return unless trains + return unless trains.length.positive? + + remaining = goods_to_deliver + trains.each do |train| + train.name = train.name.partition('+')[0] unless train.nil? + capacity = ship_capacity(train) + goods_count = [remaining, capacity].min + remaining -= goods_count + train.name += '+' + @game.class::GOODS_TRAIN + '(' + goods_count.to_s + ')' if goods_count.positive? + end + @goods_shipped = goods_to_deliver - remaining + end + + def process_run_routes(action) + super + entity = action.entity + @game.remove_goods_from_rptla(@goods_shipped) if @goods_shipped.positive? && entity == @game.rptla + @log << "#{entity.id} ships #{@goods_shipped} good to England" if @goods_shipped == 1 && entity == @game.rptla + @log << "#{entity.id} ships #{@goods_shipped} goods to England" if @goods_shipped > 1 && entity == @game.rptla + @game.route_trains(entity)&.each do |train| + train.name = train.name.partition('+')[0] unless train.nil? + end + end + end + end + end + end +end diff --git a/lib/engine/game/g_18_uruguay/step/take_loan_buy_company.rb b/lib/engine/game/g_18_uruguay/step/take_loan_buy_company.rb new file mode 100644 index 0000000000..b11a24275a --- /dev/null +++ b/lib/engine/game/g_18_uruguay/step/take_loan_buy_company.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative '../../../step/base' + +module Engine + module Game + module G18Uruguay + module Step + class TakeLoanBuyCompany < Engine::Step::BuyCompany + def actions(entity) + return [] if !entity.corporation? || entity != current_entity + return [] if entity == @game.rptla + return [] unless can_buy_company?(entity) + + actions = [] + actions << 'take_loan' if @game.can_take_loan?(entity) && !@round.loan_taken && !@game.nationalized? + actions << 'buy_company' if can_buy_company?(entity) + actions << 'pass' if can_buy_company?(entity) || (@game.can_take_loan?(entity) && !@round.loan_taken) + + actions + end + + def can_buy_company?(entity) + companies = @game.purchasable_companies(entity) + + entity == current_entity && + @game.phase.status.include?('can_buy_companies') && + companies.any? && + companies.map(&:min_price).min <= buying_power(entity) + end + + def description + 'Take Loans or Buy Company' + end + + def blocks? + true + end + + def process_take_loan(action) + entity = action.entity + @game.take_loan(entity, action.loan) + @round.loan_taken = true + end + + def round_state + super.merge({ + # has player taken a loan this or already + loan_taken: false, + }) + end + + def setup + # you can only take one loan per OR turn + @round.loan_taken = false + end + end + end + end + end +end diff --git a/lib/engine/game/g_18_uruguay/step/token.rb b/lib/engine/game/g_18_uruguay/step/token.rb new file mode 100644 index 0000000000..ae5c5a2552 --- /dev/null +++ b/lib/engine/game/g_18_uruguay/step/token.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative '../../../step/token' + +module Engine + module Game + module G18Uruguay + module Step + class Token < Engine::Step::Token + def actions(entity) + return [] if entity == @game.rptla + return [] if @game.final_operating_round? + + @round.loan_taken |= false + actions = super.map(&:clone) + if !actions.empty? && @game.can_take_loan?(entity) && !@round.loan_taken && !@game.nationalized? + actions << 'take_loan' + end + actions + end + + def process_take_loan(action) + entity = action.entity + @game.take_loan(entity, action.loan) unless @round.loan_taken + @round.loan_taken = true + end + end + end + end + end +end