diff --git a/bin/dump_building_class_info.pl b/bin/dump_building_class_info.pl new file mode 100644 index 00000000..5f22181c --- /dev/null +++ b/bin/dump_building_class_info.pl @@ -0,0 +1,57 @@ +#!/usr/bin/env perl +# One-off dump of per-leaf-building-class image/name/url/build-cost constants, consumed by +# lacuna/config/building_class_info.json (Rails' Building#get_buildings port). Analogous to +# bin/dump_body_class_info.pl for Body#get_status. +# Run inside the lacuna-server container: perl -Ilib bin/dump_building_class_info.pl +use strict; +use warnings; +use lib 'lib'; +use JSON::PP; +use File::Find; + +my @classes; +find({ + wanted => sub { + return unless /\.pm$/; + my $rel = $File::Find::name; + $rel =~ s{^lib/}{}; + (my $class = $rel) =~ s{/}{::}g; + $class =~ s{\.pm$}{}; + push @classes, $class; + }, + no_chdir => 1, +}, 'lib/Lacuna/DB/Result/Building'); + +my %out; +for my $class (sort @classes) { + (my $file = "$class.pm") =~ s{::}{/}g; + require $file; + + # Abstract/category-base classes (Building.pm itself, and the per-resource-type bases like + # Building/Energy.pm, Building/Food.pm, Building/Ore.pm, Building/Module.pm, + # Building/Permanent.pm) never override `image`/`controller_class` — the base class `confess`es + # on both (see Lacuna::DB::Result::Building) — so eval-guarding both calls is how we tell a + # true leaf class apart from an abstract base, without hardcoding the list of leaves. + my $image = eval { $class->image }; + next if $@ || !defined $image; + + my $controller_class = eval { $class->controller_class }; + next if $@ || !defined $controller_class; + + (my $controller_file = "$controller_class.pm") =~ s{::}{/}g; + require $controller_file; + my $url = eval { $controller_class->app_url }; + next if $@ || !defined $url; + + $out{$class} = { + image => $image, + name => $class->name, + url => $url, + ore_to_build => $class->ore_to_build, + water_to_build => $class->water_to_build, + food_to_build => $class->food_to_build, + energy_to_build => $class->energy_to_build, + }; +} + +print JSON::PP->new->canonical->pretty->encode(\%out); diff --git a/lacuna/app/controllers/body_controller.rb b/lacuna/app/controllers/body_controller.rb index 8cccb9a8..ff8b939b 100644 --- a/lacuna/app/controllers/body_controller.rb +++ b/lacuna/app/controllers/body_controller.rb @@ -25,8 +25,25 @@ class BodyController < ApplicationController head :not_implemented end + # Mirrors Lacuna::RPC::Body#get_buildings (lib/Lacuna/RPC/Body.pm:105-122) and + # Lacuna::RPC::Role::Building#out_buildings (lib/Lacuna/RPC/Role/Building.pm:7-38). def get_buildings - head :not_implemented + body = Body.find_by(id: params[:body_id]) + if body.nil? || !body.accessible_to?(@current_session) + render json: { error: 'Body does not exist or is not owned by you or any empire you sit.' }, + status: :not_found + return + end + + body.update!(needs_surface_refresh: false) if body.needs_surface_refresh.to_i != 0 + + buildings = body.buildings.each_with_object({}) { |b, out| out[b.id] = b.summary } + + render json: { + buildings: buildings, + body: { surface_image: body.surface }, + status: format_status(body), + } end def rearrange_buildings diff --git a/lacuna/app/controllers/empire_controller.rb b/lacuna/app/controllers/empire_controller.rb index 36e9a481..2b85c3c6 100644 --- a/lacuna/app/controllers/empire_controller.rb +++ b/lacuna/app/controllers/empire_controller.rb @@ -63,35 +63,35 @@ class EmpireController < ApplicationController end def boost_building - head :not_implemented + boost(:building) end def boost_energy - head :not_implemented + boost(:energy) end def boost_food - head :not_implemented + boost(:food) end def boost_happiness - head :not_implemented + boost(:happiness) end def boost_ore - head :not_implemented + boost(:ore) end def boost_spy_training - head :not_implemented + boost(:spy_training) end def boost_storage - head :not_implemented + boost(:storage) end def boost_water - head :not_implemented + boost(:water) end def change_password @@ -186,8 +186,9 @@ class EmpireController < ApplicationController head :not_implemented end + # Mirrors Lacuna::RPC::Empire#view_boosts (lib/Lacuna/RPC/Empire.pm:890-907). def view_boosts - head :not_implemented + render json: view_boosts_payload end def view_public_profile @@ -197,4 +198,42 @@ class EmpireController < ApplicationController def view_species_stats head :not_implemented end + + private + + # Mirrors Lacuna::RPC::Empire#boost's validation + dispatch (lib/Lacuna/RPC/Empire.pm:865-888). + # Perl's error codes (1001 "Weeks must be a positive integer", 1011 "Not enough essentia.") + # don't map cleanly onto HTTP/REST — 422 and 402 are this port's judgment-call mapping. + def boost(type) + column = Empire::BOOST_COLUMNS.fetch(type) + raw_weeks = params[:weeks] + raw_weeks = 1 if raw_weeks.nil? # Perl's `$weeks //= 1` — only defaults when omitted, not when 0. + + # Float() accepts Integer/Float (JSON-typed params) and numeric Strings (form-encoded params) + # alike, mirroring Perl's own untyped `int($weeks) == $weeks` check. + weeks = begin + Float(raw_weeks) + rescue ArgumentError, TypeError + nil + end + + unless weeks && weeks >= 0 && weeks == weeks.to_i + render json: { error: 'Weeks must be a positive integer' }, status: :unprocessable_entity + return + end + weeks = weeks.to_i + + cost = Empire::BOOST_COST_PER_WEEK * weeks + unless current_empire.essentia >= cost + render json: { error: 'Not enough essentia.' }, status: :payment_required + return + end + + current_empire.apply_boost!(column, weeks, reason: "#{column} boost", api_key: @current_session.api_key) + render json: view_boosts_payload + end + + def view_boosts_payload + { status: format_status, boosts: current_empire.view_boosts } + end end diff --git a/lacuna/app/models/body.rb b/lacuna/app/models/body.rb index d06459dd..fedeba2c 100644 --- a/lacuna/app/models/body.rb +++ b/lacuna/app/models/body.rb @@ -89,6 +89,11 @@ class Body < ApplicationRecord "#{image}-#{orbit}" end + # Mirrors Lacuna::DB::Result::Map::Body::Planet#surface (lib/Lacuna/DB/Result/Map/Body/Planet.pm:120-123). + def surface + "surface-#{image}" + end + def water CLASS_INFO.dig(perl_class, 'water') end diff --git a/lacuna/app/models/building.rb b/lacuna/app/models/building.rb index 42e3ded3..0fec7818 100644 --- a/lacuna/app/models/building.rb +++ b/lacuna/app/models/building.rb @@ -45,12 +45,21 @@ class Building < ApplicationRecord belongs_to :body + # Per-leaf-building-class image/name/url/build-cost constants, generated by + # bin/dump_building_class_info.pl from the Perl source (see that script's header comment) — + # analogous to Body::CLASS_INFO / config/body_class_info.json. + CLASS_INFO = JSON.parse(Rails.root.join('config', 'building_class_info.json').read).freeze + def perl_class @perl_class ||= self.class.connection.select_value( "SELECT class FROM building WHERE id = #{self.class.connection.quote(id)}" ) end + def class_info + CLASS_INFO.fetch(perl_class, {}) + end + # Mirrors Lacuna::DB::Result::Building's `effective_level` builder # (lib/Lacuna/DB/Result/Building.pm:140-158). def effective_level @@ -69,4 +78,65 @@ class Building < ApplicationRecord effective_level * 10_000 end + + # Mirrors Lacuna::DB::Result::Building#image_level (lib/Lacuna/DB/Result/Building.pm:75-80). + def image_level + "#{class_info['image']}#{[level, 9].min}" + end + + # Mirrors Lacuna::DB::Result::Building#current_level_cost (lib/Lacuna/DB/Result/Building.pm:181-183). + def current_level_cost + Empire::INFLATION**(level - 1) + end + + # Mirrors Lacuna::DB::Result::Building#upgrade_status (lib/Lacuna/DB/Result/Building.pm:720-733). + def upgrade_status + return nil if is_upgrading.to_i.zero? + + { + seconds_remaining: upgrade_ends.to_i - Time.now.utc.to_i, + start: LacunaDateFormat.format(upgrade_started), + end: LacunaDateFormat.format(upgrade_ends), + } + end + + # Mirrors Lacuna::DB::Result::Building#work_seconds_remaining (lib/Lacuna/DB/Result/Building.pm:1076-1080). + def work_seconds_remaining + return 0 if is_working.to_i.zero? + + [work_ends.to_i - Time.now.utc.to_i, 0].max + end + + # Mirrors Lacuna::DB::Result::Building#get_repair_costs (lib/Lacuna/DB/Result/Building.pm:1163-1173). + # NOTE: Perl uses sprintf('%.0f', ...) for the four amounts; Ruby's Float#round may round .5 + # cases differently from glibc's printf rounding — a documented minor rounding-mode gap. + def repair_costs + damage = 100 - efficiency + damage_cost = current_level_cost * damage / 100.0 + { + ore: (class_info['ore_to_build'].to_f * damage_cost).round, + water: (class_info['water_to_build'].to_f * damage_cost).round, + food: (class_info['food_to_build'].to_f * damage_cost).round, + energy: (class_info['energy_to_build'].to_f * damage_cost).round, + } + end + + # Mirrors the per-building hash built inside Lacuna::RPC::Role::Building#out_buildings + # (lib/Lacuna/RPC/Role/Building.pm:7-38). + def summary + out = { + url: class_info['url'], image: image_level, name: class_info['name'], + x: x, y: y, level: level, efficiency: efficiency, + } + out[:pending_build] = upgrade_status if is_upgrading.to_i != 0 + if is_working.to_i != 0 + out[:work] = { + seconds_remaining: work_seconds_remaining, + start: LacunaDateFormat.format(work_started), + end: LacunaDateFormat.format(work_ends), + } + end + out[:repair_costs] = repair_costs if efficiency < 100 + out + end end diff --git a/lacuna/app/models/empire.rb b/lacuna/app/models/empire.rb index d9087378..11912316 100644 --- a/lacuna/app/models/empire.rb +++ b/lacuna/app/models/empire.rb @@ -28,12 +28,90 @@ class Empire < ApplicationRecord STATION_CLASS = Body::STATION_CLASS + # Mirrors the `$type` column names Lacuna::RPC::Empire's boost_* methods pass to their shared + # private `boost()` helper (lib/Lacuna/RPC/Empire.pm:825-888) — keyed by the RPC method's + # boost-type suffix (`boost_ore` -> :ore) since that's what the controller dispatches on. + BOOST_COLUMNS = { + building: :building_boost, + energy: :energy_boost, + food: :food_boost, + happiness: :happiness_boost, + ore: :ore_boost, + spy_training: :spy_training_boost, + storage: :storage_boost, + water: :water_boost, + }.freeze + + # Flat 5 essentia/week — hardcoded in Perl too (lib/Lacuna/RPC/Empire.pm:878), no + # Lacuna::Constants entry on either side. + BOOST_COST_PER_WEEK = 5 + # Mirrors Lacuna::DB::Result::Empire#essentia (lib/Lacuna/DB/Result/Empire.pm:265-272) — # not a column, the sum of three. def essentia essentia_free + essentia_game + essentia_paid end + # Mirrors Lacuna::RPC::Empire#view_boosts's `boosts` payload (lib/Lacuna/RPC/Empire.pm:890-907). + # Fixed hash, all 8 keys always present — the underlying columns are non-nullable, so even an + # "unboosted" empire has a real (past) datetime in each, never null. + def view_boosts + BOOST_COLUMNS.transform_values { |col| LacunaDateFormat.format(self[col]) } + end + + # Mirrors the mutation body of Lacuna::RPC::Empire#boost (lib/Lacuna/RPC/Empire.pm:865-888). + # `weeks` validation and the essentia-sufficiency check are the caller's job (EmpireController) + # — this assumes both have already passed, mirroring how Perl's `boost` does its own validation + # inline before ever touching the DB. + def apply_boost!(column, weeks, reason:, api_key: nil) + transaction do + spend_essentia(amount: BOOST_COST_PER_WEEK * weeks, reason: reason, api_key: api_key) + + now = Time.now.utc + current = self[column] + start = current && current > now ? current : now + + # Perl's `$empire->planets->update({...})` is a single bulk UPDATE across every planet the + # empire owns, unconditionally on every boost call regardless of type. + bodies.update_all(needs_recalc: true, boost_enabled: true) + + self[column] = start + (7 * weeks).days + save! + end + end + + # Partial port of Lacuna::DB::Result::Empire#_adjust_essentia's spend path + # (lib/Lacuna/DB/Result/Empire.pm:412-505) — only the negative-amount (spend) branch that + # Lacuna::RPC::Empire#boost exercises via Perl's #spend_essentia. The positive/"add" path and + # the other_empire-transfer bookkeeping (#add_essentia, #transfer_essentia) aren't needed here + # and are intentionally not ported. + def spend_essentia(amount:, reason:, api_key: nil) + reload # mirrors Perl's `$self->discard_changes` at the top of `_adjust_essentia` + + # Cascade the deduction essentia_free -> essentia_game -> essentia_paid, in that fixed order, + # zeroing out each column as needed until the amount is absorbed (or all three are + # exhausted, in which case essentia_free goes negative — an overdraw Perl's own accounting + # allows, though the controller-level sufficiency check should prevent it in practice). + remaining = -amount.to_f + %i[essentia_free essentia_game essentia_paid].each do |col| + remaining += self[col] + if remaining >= 0 + self[col] = remaining + remaining = 0 + break + else + self[col] = 0 + end + end + self.essentia_free += remaining if remaining.negative? + + EssentiaLog.create!( + empire_id: id, empire_name: name, amount: -amount.to_f, description: reason, + transaction_id: nil, api_key: api_key.presence, from_id: 0, from_name: '', + date_stamp: Time.now.utc, + ) + end + # Mirrors Lacuna::DB::Result::Empire#self_destruct_date_formatted (lib/Lacuna/DB/Result/Empire.pm:255-258). def self_destruct_date_formatted LacunaDateFormat.format(self_destruct_date) diff --git a/lacuna/config/building_class_info.json b/lacuna/config/building_class_info.json new file mode 100644 index 00000000..32180dd7 --- /dev/null +++ b/lacuna/config/building_class_info.json @@ -0,0 +1,1406 @@ +{ + "Lacuna::DB::Result::Building::Archaeology" : { + "energy_to_build" : 240, + "food_to_build" : 210, + "image" : "archaeology", + "name" : "Archaeology Ministry", + "ore_to_build" : 210, + "url" : "/archaeology", + "water_to_build" : 190 + }, + "Lacuna::DB::Result::Building::Capitol" : { + "energy_to_build" : 350, + "food_to_build" : 350, + "image" : "capitol", + "name" : "Capitol", + "ore_to_build" : 350, + "url" : "/capitol", + "water_to_build" : 350 + }, + "Lacuna::DB::Result::Building::CloakingLab" : { + "energy_to_build" : 240, + "food_to_build" : 220, + "image" : "cloakinglab", + "name" : "Cloaking Lab", + "ore_to_build" : 240, + "url" : "/cloakinglab", + "water_to_build" : 220 + }, + "Lacuna::DB::Result::Building::DeployedBleeder" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "deployedbleeder", + "name" : "Deployed Bleeder", + "ore_to_build" : 0, + "url" : "/deployedbleeder", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Development" : { + "energy_to_build" : 77, + "food_to_build" : 78, + "image" : "devel", + "name" : "Development Ministry", + "ore_to_build" : 77, + "url" : "/development", + "water_to_build" : 78 + }, + "Lacuna::DB::Result::Building::DistributionCenter" : { + "energy_to_build" : 250, + "food_to_build" : 250, + "image" : "distributioncenter", + "name" : "Distribution Center", + "ore_to_build" : 250, + "url" : "/distributioncenter", + "water_to_build" : 250 + }, + "Lacuna::DB::Result::Building::Embassy" : { + "energy_to_build" : 65, + "food_to_build" : 65, + "image" : "embassy", + "name" : "Embassy", + "ore_to_build" : 65, + "url" : "/embassy", + "water_to_build" : 65 + }, + "Lacuna::DB::Result::Building::Energy::Fission" : { + "energy_to_build" : 365, + "food_to_build" : 250, + "image" : "fission", + "name" : "Fission Reactor", + "ore_to_build" : 365, + "url" : "/fission", + "water_to_build" : 330 + }, + "Lacuna::DB::Result::Building::Energy::Fusion" : { + "energy_to_build" : 340, + "food_to_build" : 270, + "image" : "fusion", + "name" : "Fusion Reactor", + "ore_to_build" : 320, + "url" : "/fusion", + "water_to_build" : 240 + }, + "Lacuna::DB::Result::Building::Energy::Geo" : { + "energy_to_build" : 18, + "food_to_build" : 140, + "image" : "geo", + "name" : "Geo Energy Plant", + "ore_to_build" : 140, + "url" : "/geo", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::Energy::Hydrocarbon" : { + "energy_to_build" : 260, + "food_to_build" : 300, + "image" : "hydrocarbon", + "name" : "Hydrocarbon Energy Plant", + "ore_to_build" : 330, + "url" : "/hydrocarbon", + "water_to_build" : 300 + }, + "Lacuna::DB::Result::Building::Energy::Reserve" : { + "energy_to_build" : 100, + "food_to_build" : 100, + "image" : "energy-reserve", + "name" : "Energy Reserve", + "ore_to_build" : 100, + "url" : "/energyreserve", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::Energy::Singularity" : { + "energy_to_build" : 1105, + "food_to_build" : 1000, + "image" : "singularity", + "name" : "Singularity Energy Plant", + "ore_to_build" : 1400, + "url" : "/singularity", + "water_to_build" : 1100 + }, + "Lacuna::DB::Result::Building::Energy::Waste" : { + "energy_to_build" : 170, + "food_to_build" : 180, + "image" : "wasteenergy", + "name" : "Waste Energy Plant", + "ore_to_build" : 150, + "url" : "/wasteenergy", + "water_to_build" : 190 + }, + "Lacuna::DB::Result::Building::EntertainmentDistrict" : { + "energy_to_build" : 160, + "food_to_build" : 160, + "image" : "entertainment", + "name" : "Entertainment District", + "ore_to_build" : 190, + "url" : "/entertainment", + "water_to_build" : 160 + }, + "Lacuna::DB::Result::Building::Espionage" : { + "energy_to_build" : 77, + "food_to_build" : 78, + "image" : "espionage", + "name" : "Espionage Ministry", + "ore_to_build" : 77, + "url" : "/espionage", + "water_to_build" : 78 + }, + "Lacuna::DB::Result::Building::Food::Algae" : { + "energy_to_build" : 115, + "food_to_build" : 10, + "image" : "algae", + "name" : "Algae Cropper", + "ore_to_build" : 105, + "url" : "/algae", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::Food::Apple" : { + "energy_to_build" : 50, + "food_to_build" : 25, + "image" : "apples", + "name" : "Apple Orchard", + "ore_to_build" : 55, + "url" : "/apple", + "water_to_build" : 175 + }, + "Lacuna::DB::Result::Building::Food::Bean" : { + "energy_to_build" : 61, + "food_to_build" : 10, + "image" : "beans", + "name" : "Amalgus Bean Plantation", + "ore_to_build" : 55, + "url" : "/bean", + "water_to_build" : 40 + }, + "Lacuna::DB::Result::Building::Food::Beeldeban" : { + "energy_to_build" : 100, + "food_to_build" : 100, + "image" : "beeldeban", + "name" : "Beeldeban Herder", + "ore_to_build" : 125, + "url" : "/beeldeban", + "water_to_build" : 76 + }, + "Lacuna::DB::Result::Building::Food::Bread" : { + "energy_to_build" : 150, + "food_to_build" : 150, + "image" : "bread", + "name" : "Bread Bakery", + "ore_to_build" : 150, + "url" : "/bread", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Food::Burger" : { + "energy_to_build" : 135, + "food_to_build" : 135, + "image" : "burger", + "name" : "Malcud Burger Packer", + "ore_to_build" : 135, + "url" : "/burger", + "water_to_build" : 135 + }, + "Lacuna::DB::Result::Building::Food::Cheese" : { + "energy_to_build" : 175, + "food_to_build" : 175, + "image" : "cheese", + "name" : "Cheese Maker", + "ore_to_build" : 175, + "url" : "/cheese", + "water_to_build" : 175 + }, + "Lacuna::DB::Result::Building::Food::Chip" : { + "energy_to_build" : 160, + "food_to_build" : 160, + "image" : "chips", + "name" : "Denton Root Chip Frier", + "ore_to_build" : 160, + "url" : "/chip", + "water_to_build" : 160 + }, + "Lacuna::DB::Result::Building::Food::Cider" : { + "energy_to_build" : 170, + "food_to_build" : 160, + "image" : "cider", + "name" : "Apple Cider Bottler", + "ore_to_build" : 150, + "url" : "/cider", + "water_to_build" : 170 + }, + "Lacuna::DB::Result::Building::Food::Corn" : { + "energy_to_build" : 70, + "food_to_build" : 10, + "image" : "corn", + "name" : "Corn Plantation", + "ore_to_build" : 55, + "url" : "/corn", + "water_to_build" : 83 + }, + "Lacuna::DB::Result::Building::Food::CornMeal" : { + "energy_to_build" : 140, + "food_to_build" : 140, + "image" : "meal", + "name" : "Corn Meal Grinder", + "ore_to_build" : 140, + "url" : "/cornmeal", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Food::Dairy" : { + "energy_to_build" : 100, + "food_to_build" : 200, + "image" : "dairy", + "name" : "Dairy Farm", + "ore_to_build" : 150, + "url" : "/dairy", + "water_to_build" : 200 + }, + "Lacuna::DB::Result::Building::Food::Lapis" : { + "energy_to_build" : 71, + "food_to_build" : 15, + "image" : "lapis", + "name" : "Lapis Orchard", + "ore_to_build" : 75, + "url" : "/lapis", + "water_to_build" : 140 + }, + "Lacuna::DB::Result::Building::Food::Malcud" : { + "energy_to_build" : 60, + "food_to_build" : 10, + "image" : "malcud", + "name" : "Malcud Fungus Farm", + "ore_to_build" : 46, + "url" : "/malcud", + "water_to_build" : 30 + }, + "Lacuna::DB::Result::Building::Food::Pancake" : { + "energy_to_build" : 125, + "food_to_build" : 125, + "image" : "pancake", + "name" : "Potato Pancake Factory", + "ore_to_build" : 125, + "url" : "/pancake", + "water_to_build" : 125 + }, + "Lacuna::DB::Result::Building::Food::Pie" : { + "energy_to_build" : 135, + "food_to_build" : 135, + "image" : "pie", + "name" : "Lapis Pie Bakery", + "ore_to_build" : 135, + "url" : "/pie", + "water_to_build" : 135 + }, + "Lacuna::DB::Result::Building::Food::Potato" : { + "energy_to_build" : 90, + "food_to_build" : 10, + "image" : "potato", + "name" : "Potato Patch", + "ore_to_build" : 56, + "url" : "/potato", + "water_to_build" : 10 + }, + "Lacuna::DB::Result::Building::Food::Reserve" : { + "energy_to_build" : 45, + "food_to_build" : 45, + "image" : "food-reserve", + "name" : "Food Reserve", + "ore_to_build" : 45, + "url" : "/foodreserve", + "water_to_build" : 45 + }, + "Lacuna::DB::Result::Building::Food::Root" : { + "energy_to_build" : 100, + "food_to_build" : 10, + "image" : "roots", + "name" : "Denton Root Patch", + "ore_to_build" : 52, + "url" : "/denton", + "water_to_build" : 10 + }, + "Lacuna::DB::Result::Building::Food::Shake" : { + "energy_to_build" : 135, + "food_to_build" : 125, + "image" : "shake", + "name" : "Beeldeban Protein Shake Factory", + "ore_to_build" : 135, + "url" : "/shake", + "water_to_build" : 125 + }, + "Lacuna::DB::Result::Building::Food::Soup" : { + "energy_to_build" : 125, + "food_to_build" : 125, + "image" : "cannery", + "name" : "Amalgus Bean Soup Cannery", + "ore_to_build" : 168, + "url" : "/soup", + "water_to_build" : 125 + }, + "Lacuna::DB::Result::Building::Food::Syrup" : { + "energy_to_build" : 150, + "food_to_build" : 150, + "image" : "syrup", + "name" : "Algae Syrup Bottler", + "ore_to_build" : 150, + "url" : "/syrup", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Food::Wheat" : { + "energy_to_build" : 100, + "food_to_build" : 15, + "image" : "wheat", + "name" : "Wheat Farm", + "ore_to_build" : 75, + "url" : "/wheat", + "water_to_build" : 20 + }, + "Lacuna::DB::Result::Building::GasGiantLab" : { + "energy_to_build" : 300, + "food_to_build" : 300, + "image" : "gas-giant-lab", + "name" : "Gas Giant Lab", + "ore_to_build" : 340, + "url" : "/gasgiantlab", + "water_to_build" : 300 + }, + "Lacuna::DB::Result::Building::GeneticsLab" : { + "energy_to_build" : 330, + "food_to_build" : 315, + "image" : "geneticslab", + "name" : "Genetics Lab", + "ore_to_build" : 300, + "url" : "/geneticslab", + "water_to_build" : 280 + }, + "Lacuna::DB::Result::Building::IntelTraining" : { + "energy_to_build" : 99, + "food_to_build" : 100, + "image" : "inteltraining", + "name" : "Intel Training", + "ore_to_build" : 99, + "url" : "/inteltraining", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::Intelligence" : { + "energy_to_build" : 82, + "food_to_build" : 83, + "image" : "intelligence", + "name" : "Intelligence Ministry", + "ore_to_build" : 82, + "url" : "/intelligence", + "water_to_build" : 83 + }, + "Lacuna::DB::Result::Building::LCOTa" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcota", + "name" : "Lost City of Tyleon (A)", + "ore_to_build" : 4250, + "url" : "/lcota", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTb" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcotb", + "name" : "Lost City of Tyleon (B)", + "ore_to_build" : 4250, + "url" : "/lcotb", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTc" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcoti", + "name" : "Lost City of Tyleon (C)", + "ore_to_build" : 4250, + "url" : "/lcotc", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTd" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcoth", + "name" : "Lost City of Tyleon (D)", + "ore_to_build" : 4250, + "url" : "/lcotd", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTe" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcotg", + "name" : "Lost City of Tyleon (E)", + "ore_to_build" : 4250, + "url" : "/lcote", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTf" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcotf", + "name" : "Lost City of Tyleon (F)", + "ore_to_build" : 4250, + "url" : "/lcotf", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTg" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcotc", + "name" : "Lost City of Tyleon (G)", + "ore_to_build" : 4250, + "url" : "/lcotg", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTh" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcotd", + "name" : "Lost City of Tyleon (H)", + "ore_to_build" : 4250, + "url" : "/lcoth", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LCOTi" : { + "energy_to_build" : 4250, + "food_to_build" : 4250, + "image" : "lcote", + "name" : "Lost City of Tyleon (I)", + "ore_to_build" : 4250, + "url" : "/lcoti", + "water_to_build" : 4250 + }, + "Lacuna::DB::Result::Building::LuxuryHousing" : { + "energy_to_build" : 640, + "food_to_build" : 640, + "image" : "luxuryhousing", + "name" : "Luxury Housing", + "ore_to_build" : 760, + "url" : "/luxuryhousing", + "water_to_build" : 760 + }, + "Lacuna::DB::Result::Building::MayhemTraining" : { + "energy_to_build" : 99, + "food_to_build" : 100, + "image" : "mayhemtraining", + "name" : "Mayhem Training", + "ore_to_build" : 99, + "url" : "/mayhemtraining", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::MercenariesGuild" : { + "energy_to_build" : 800, + "food_to_build" : 700, + "image" : "mercenariesguild", + "name" : "Mercenaries Guild", + "ore_to_build" : 900, + "url" : "/mercenariesguild", + "water_to_build" : 700 + }, + "Lacuna::DB::Result::Building::MissionCommand" : { + "energy_to_build" : 90, + "food_to_build" : 85, + "image" : "missioncommand", + "name" : "Mission Command", + "ore_to_build" : 110, + "url" : "/missioncommand", + "water_to_build" : 90 + }, + "Lacuna::DB::Result::Building::Module::ArtMuseum" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "artmuseum", + "name" : "Art Museum", + "ore_to_build" : 500, + "url" : "/artmuseum", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Module::CulinaryInstitute" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "culinaryinstitute", + "name" : "Culinary Institute", + "ore_to_build" : 500, + "url" : "/culinaryinstitute", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Module::IBS" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "ibs", + "name" : "Interstellar Broadcast System", + "ore_to_build" : 500, + "url" : "/ibs", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Module::OperaHouse" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "operahouse", + "name" : "Opera House", + "ore_to_build" : 500, + "url" : "/operahouse", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Module::Parliament" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "parliament", + "name" : "Parliament", + "ore_to_build" : 500, + "url" : "/parliament", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Module::PoliceStation" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "policestation", + "name" : "Police Station", + "ore_to_build" : 500, + "url" : "/policestation", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Module::StationCommand" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "stationcommand", + "name" : "Station Command Center", + "ore_to_build" : 500, + "url" : "/stationcommand", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::Module::Warehouse" : { + "energy_to_build" : 500, + "food_to_build" : 100, + "image" : "warehouse", + "name" : "Warehouse", + "ore_to_build" : 500, + "url" : "/warehouse", + "water_to_build" : 150 + }, + "Lacuna::DB::Result::Building::MunitionsLab" : { + "energy_to_build" : 250, + "food_to_build" : 220, + "image" : "munitionslab", + "name" : "Munitions Lab", + "ore_to_build" : 250, + "url" : "/munitionslab", + "water_to_build" : 220 + }, + "Lacuna::DB::Result::Building::Network19" : { + "energy_to_build" : 98, + "food_to_build" : 98, + "image" : "network19", + "name" : "Network 19 Affiliate", + "ore_to_build" : 100, + "url" : "/network19", + "water_to_build" : 98 + }, + "Lacuna::DB::Result::Building::Observatory" : { + "energy_to_build" : 63, + "food_to_build" : 63, + "image" : "observatory", + "name" : "Observatory", + "ore_to_build" : 63, + "url" : "/observatory", + "water_to_build" : 63 + }, + "Lacuna::DB::Result::Building::Ore::Mine" : { + "energy_to_build" : 100, + "food_to_build" : 85, + "image" : "mine", + "name" : "Mine", + "ore_to_build" : 10, + "url" : "/mine", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::Ore::Ministry" : { + "energy_to_build" : 139, + "food_to_build" : 137, + "image" : "miningministry", + "name" : "Mining Ministry", + "ore_to_build" : 137, + "url" : "/miningministry", + "water_to_build" : 137 + }, + "Lacuna::DB::Result::Building::Ore::Refinery" : { + "energy_to_build" : 148, + "food_to_build" : 147, + "image" : "orerefinery", + "name" : "Ore Refinery", + "ore_to_build" : 147, + "url" : "/orerefinery", + "water_to_build" : 148 + }, + "Lacuna::DB::Result::Building::Ore::Storage" : { + "energy_to_build" : 10, + "food_to_build" : 10, + "image" : "orestorage", + "name" : "Ore Storage Tanks", + "ore_to_build" : 10, + "url" : "/orestorage", + "water_to_build" : 10 + }, + "Lacuna::DB::Result::Building::Oversight" : { + "energy_to_build" : 50, + "food_to_build" : 50, + "image" : "oversight", + "name" : "Oversight Ministry", + "ore_to_build" : 50, + "url" : "/oversight", + "water_to_build" : 50 + }, + "Lacuna::DB::Result::Building::Park" : { + "energy_to_build" : 20, + "food_to_build" : 20, + "image" : "park", + "name" : "Park", + "ore_to_build" : 20, + "url" : "/park", + "water_to_build" : 20 + }, + "Lacuna::DB::Result::Building::Permanent::AlgaePond" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "algaepond", + "name" : "Algae Pond", + "ore_to_build" : 0, + "url" : "/algaepond", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::AmalgusMeadow" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "amalgusmeadow", + "name" : "Amalgus Meadow", + "ore_to_build" : 0, + "url" : "/amalgusmeadow", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach1" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach1", + "name" : "Beach [1]", + "ore_to_build" : 0, + "url" : "/beach1", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach10" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach10", + "name" : "Beach [10]", + "ore_to_build" : 0, + "url" : "/beach10", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach11" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach11", + "name" : "Beach [11]", + "ore_to_build" : 0, + "url" : "/beach11", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach12" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach12", + "name" : "Beach [12]", + "ore_to_build" : 0, + "url" : "/beach12", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach13" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach13", + "name" : "Beach [13]", + "ore_to_build" : 0, + "url" : "/beach13", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach2" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach2", + "name" : "Beach [2]", + "ore_to_build" : 0, + "url" : "/beach2", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach3" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach3", + "name" : "Beach [3]", + "ore_to_build" : 0, + "url" : "/beach3", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach4" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach4", + "name" : "Beach [4]", + "ore_to_build" : 0, + "url" : "/beach4", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach5" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach5", + "name" : "Beach [5]", + "ore_to_build" : 0, + "url" : "/beach5", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach6" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach6", + "name" : "Beach [6]", + "ore_to_build" : 0, + "url" : "/beach6", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach7" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach7", + "name" : "Beach [7]", + "ore_to_build" : 0, + "url" : "/beach7", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach8" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach8", + "name" : "Beach [8]", + "ore_to_build" : 0, + "url" : "/beach8", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Beach9" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beach9", + "name" : "Beach [9]", + "ore_to_build" : 0, + "url" : "/beach9", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::BeeldebanNest" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "beeldebannest", + "name" : "Beeldeban Nest", + "ore_to_build" : 0, + "url" : "/beeldebannest", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::BlackHoleGenerator" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "blackholegenerator", + "name" : "Black Hole Generator", + "ore_to_build" : 0, + "url" : "/blackholegenerator", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::CitadelOfKnope" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "citadelofknope", + "name" : "Citadel of Knope", + "ore_to_build" : 0, + "url" : "/citadelofknope", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::CrashedShipSite" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "crashedshipsite", + "name" : "Crashed Ship Site", + "ore_to_build" : 0, + "url" : "/crashedshipsite", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Crater" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "crater", + "name" : "Crater", + "ore_to_build" : 0, + "url" : "/crater", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::DentonBrambles" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "dentonbrambles", + "name" : "Denton Brambles", + "ore_to_build" : 0, + "url" : "/dentonbrambles", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::EssentiaVein" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "essentiavein", + "name" : "Essentia Vein", + "ore_to_build" : 0, + "url" : "/essentiavein", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Fissure" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "fissure", + "name" : "Fissure", + "ore_to_build" : 0, + "url" : "/fissure", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::GasGiantPlatform" : { + "energy_to_build" : 1500, + "food_to_build" : 0, + "image" : "gas-giant-platform", + "name" : "Gas Giant Settlement Platform", + "ore_to_build" : 1500, + "url" : "/gasgiantplatform", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::GeoThermalVent" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "geothermalvent", + "name" : "Geo Thermal Vent", + "ore_to_build" : 0, + "url" : "/geothermalvent", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::GratchsGauntlet" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "gratchsgauntlet", + "name" : "Gratch's Gauntlet", + "ore_to_build" : 0, + "url" : "/gratchsgauntlet", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::GreatBallOfJunk" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "greatballofjunk", + "name" : "Great Ball of Junk", + "ore_to_build" : 0, + "url" : "/greatballofjunk", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Grove" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "grove", + "name" : "Grove of Trees", + "ore_to_build" : 0, + "url" : "/grove", + "water_to_build" : 1 + }, + "Lacuna::DB::Result::Building::Permanent::HallsOfVrbansk" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "hallsofvrbansk", + "name" : "Halls of Vrbansk", + "ore_to_build" : 0, + "url" : "/hallsofvrbansk", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::InterDimensionalRift" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "interdimensionalrift", + "name" : "Interdimensional Rift", + "ore_to_build" : 0, + "url" : "/interdimensionalrift", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::JunkHengeSculpture" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "junkhengesculpture", + "name" : "Junk Henge Sculpture", + "ore_to_build" : 0, + "url" : "/junkhengesculpture", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::KalavianRuins" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "kalavianruins", + "name" : "Kalavian Ruins", + "ore_to_build" : 0, + "url" : "/kalavianruins", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::KasternsKeep" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "kasternskeep", + "name" : "Kastern's Keep", + "ore_to_build" : 0, + "url" : "/kasternskeep", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Lagoon" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "lagoon", + "name" : "Lagoon", + "ore_to_build" : 1, + "url" : "/lagoon", + "water_to_build" : 1 + }, + "Lacuna::DB::Result::Building::Permanent::Lake" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "lake", + "name" : "Lake", + "ore_to_build" : 0, + "url" : "/lake", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::LapisForest" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "lapisforest", + "name" : "Lapis Forest", + "ore_to_build" : 0, + "url" : "/lapisforest", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::LibraryOfJith" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "libraryjith", + "name" : "Library of Jith", + "ore_to_build" : 0, + "url" : "/libraryofjith", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::MalcudField" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "malcudfield", + "name" : "Malcud Field", + "ore_to_build" : 0, + "url" : "/malcudfield", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::MassadsHenge" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "massadshenge", + "name" : "Massad's Henge", + "ore_to_build" : 0, + "url" : "/massadshenge", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::MetalJunkArches" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "metaljunkarches", + "name" : "Metal Junk Arches", + "ore_to_build" : 0, + "url" : "/metaljunkarches", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::NaturalSpring" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "naturalspring", + "name" : "Natural Spring", + "ore_to_build" : 0, + "url" : "/naturalspring", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::OracleOfAnid" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "oracleanid", + "name" : "Oracle of Anid", + "ore_to_build" : 0, + "url" : "/oracleofanid", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::PantheonOfHagness" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "pantheonofhagness", + "name" : "Pantheon of Hagness", + "ore_to_build" : 0, + "url" : "/pantheonofhagness", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::PyramidJunkSculpture" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "pyramidjunksculpture", + "name" : "Pyramid Junk Sculpture", + "ore_to_build" : 0, + "url" : "/pyramidjunksculpture", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Ravine" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "ravine", + "name" : "Ravine", + "ore_to_build" : 0, + "url" : "/ravine", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::RockyOutcrop" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "rockyoutcrop", + "name" : "Rocky Outcropping", + "ore_to_build" : 0, + "url" : "/rockyoutcrop", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Sand" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "sand", + "name" : "Patch of Sand", + "ore_to_build" : 1, + "url" : "/sand", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::SpaceJunkPark" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "spacejunkpark", + "name" : "Space Junk Park", + "ore_to_build" : 0, + "url" : "/spacejunkpark", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::TempleOfTheDrajilites" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "templedrajilites", + "name" : "Temple of the Drajilites", + "ore_to_build" : 0, + "url" : "/templeofthedrajilites", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::TerraformingPlatform" : { + "energy_to_build" : 800, + "food_to_build" : 0, + "image" : "terraformingplatform", + "name" : "Terraforming Platform", + "ore_to_build" : 800, + "url" : "/terraformingplatform", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::TheDillonForge" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "thedillonforge", + "name" : "The Dillon Forge", + "ore_to_build" : 0, + "url" : "/thedillonforge", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::Permanent::Volcano" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "volcano", + "name" : "Volcano", + "ore_to_build" : 0, + "url" : "/volcano", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::PilotTraining" : { + "energy_to_build" : 230, + "food_to_build" : 220, + "image" : "pilottraining", + "name" : "Pilot Training Facility", + "ore_to_build" : 220, + "url" : "/pilottraining", + "water_to_build" : 230 + }, + "Lacuna::DB::Result::Building::PlanetaryCommand" : { + "energy_to_build" : 320, + "food_to_build" : 320, + "image" : "command", + "name" : "Planetary Command Center", + "ore_to_build" : 320, + "url" : "/planetarycommand", + "water_to_build" : 320 + }, + "Lacuna::DB::Result::Building::PoliticsTraining" : { + "energy_to_build" : 99, + "food_to_build" : 100, + "image" : "politicstraining", + "name" : "Politics Training", + "ore_to_build" : 99, + "url" : "/politicstraining", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::Propulsion" : { + "energy_to_build" : 220, + "food_to_build" : 220, + "image" : "propulsion", + "name" : "Propulsion System Factory", + "ore_to_build" : 220, + "url" : "/propulsion", + "water_to_build" : 220 + }, + "Lacuna::DB::Result::Building::SAW" : { + "energy_to_build" : 250, + "food_to_build" : 180, + "image" : "saw", + "name" : "Shield Against Weapons", + "ore_to_build" : 250, + "url" : "/saw", + "water_to_build" : 200 + }, + "Lacuna::DB::Result::Building::SSLa" : { + "energy_to_build" : 350, + "food_to_build" : 230, + "image" : "ssla", + "name" : "Space Station Lab (A)", + "ore_to_build" : 370, + "url" : "/ssla", + "water_to_build" : 260 + }, + "Lacuna::DB::Result::Building::SSLb" : { + "energy_to_build" : 350, + "food_to_build" : 230, + "image" : "sslb", + "name" : "Space Station Lab (B)", + "ore_to_build" : 370, + "url" : "/sslb", + "water_to_build" : 260 + }, + "Lacuna::DB::Result::Building::SSLc" : { + "energy_to_build" : 350, + "food_to_build" : 230, + "image" : "sslc", + "name" : "Space Station Lab (C)", + "ore_to_build" : 370, + "url" : "/sslc", + "water_to_build" : 260 + }, + "Lacuna::DB::Result::Building::SSLd" : { + "energy_to_build" : 350, + "food_to_build" : 230, + "image" : "ssld", + "name" : "Space Station Lab (D)", + "ore_to_build" : 370, + "url" : "/ssld", + "water_to_build" : 260 + }, + "Lacuna::DB::Result::Building::Security" : { + "energy_to_build" : 100, + "food_to_build" : 90, + "image" : "security", + "name" : "Security Ministry", + "ore_to_build" : 120, + "url" : "/security", + "water_to_build" : 90 + }, + "Lacuna::DB::Result::Building::Shipyard" : { + "energy_to_build" : 75, + "food_to_build" : 75, + "image" : "shipyard", + "name" : "Shipyard", + "ore_to_build" : 75, + "url" : "/shipyard", + "water_to_build" : 75 + }, + "Lacuna::DB::Result::Building::SpacePort" : { + "energy_to_build" : 180, + "food_to_build" : 160, + "image" : "spaceport", + "name" : "Space Port", + "ore_to_build" : 220, + "url" : "/spaceport", + "water_to_build" : 160 + }, + "Lacuna::DB::Result::Building::Stockpile" : { + "energy_to_build" : 330, + "food_to_build" : 330, + "image" : "stockpile", + "name" : "Stockpile", + "ore_to_build" : 330, + "url" : "/stockpile", + "water_to_build" : 330 + }, + "Lacuna::DB::Result::Building::SubspaceSupplyDepot" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "subspacesupplydepot", + "name" : "Subspace Supply Depot", + "ore_to_build" : 0, + "url" : "/subspacesupplydepot", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::SupplyPod" : { + "energy_to_build" : 0, + "food_to_build" : 0, + "image" : "supply_pod", + "name" : "Supply Pod", + "ore_to_build" : 0, + "url" : "/supplypod", + "water_to_build" : 0 + }, + "Lacuna::DB::Result::Building::TerraformingLab" : { + "energy_to_build" : 340, + "food_to_build" : 310, + "image" : "terraforminglab", + "name" : "Terraforming Lab", + "ore_to_build" : 310, + "url" : "/terraforminglab", + "water_to_build" : 290 + }, + "Lacuna::DB::Result::Building::TheftTraining" : { + "energy_to_build" : 99, + "food_to_build" : 100, + "image" : "thefttraining", + "name" : "Theft Training", + "ore_to_build" : 99, + "url" : "/thefttraining", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::ThemePark" : { + "energy_to_build" : 1005, + "food_to_build" : 500, + "image" : "themepark", + "name" : "Theme Park", + "ore_to_build" : 900, + "url" : "/themepark", + "water_to_build" : 715 + }, + "Lacuna::DB::Result::Building::Trade" : { + "energy_to_build" : 75, + "food_to_build" : 75, + "image" : "trade", + "name" : "Trade Ministry", + "ore_to_build" : 75, + "url" : "/trade", + "water_to_build" : 75 + }, + "Lacuna::DB::Result::Building::Transporter" : { + "energy_to_build" : 800, + "food_to_build" : 700, + "image" : "transporter", + "name" : "Subspace Transporter", + "ore_to_build" : 900, + "url" : "/transporter", + "water_to_build" : 700 + }, + "Lacuna::DB::Result::Building::University" : { + "energy_to_build" : 190, + "food_to_build" : 190, + "image" : "university", + "name" : "University", + "ore_to_build" : 190, + "url" : "/university", + "water_to_build" : 190 + }, + "Lacuna::DB::Result::Building::Waste::Digester" : { + "energy_to_build" : 95, + "food_to_build" : 75, + "image" : "wastedigester", + "name" : "Waste Digester", + "ore_to_build" : 83, + "url" : "/wastedigester", + "water_to_build" : 95 + }, + "Lacuna::DB::Result::Building::Waste::Exchanger" : { + "energy_to_build" : 360, + "food_to_build" : 320, + "image" : "wasteexchanger", + "name" : "Waste Exchanger", + "ore_to_build" : 326, + "url" : "/wasteexchanger", + "water_to_build" : 410 + }, + "Lacuna::DB::Result::Building::Waste::Recycling" : { + "energy_to_build" : 75, + "food_to_build" : 75, + "image" : "wasterecycling", + "name" : "Waste Recycling Center", + "ore_to_build" : 70, + "url" : "/wasterecycling", + "water_to_build" : 100 + }, + "Lacuna::DB::Result::Building::Waste::Sequestration" : { + "energy_to_build" : 10, + "food_to_build" : 10, + "image" : "wastesequestration", + "name" : "Waste Sequestration Well", + "ore_to_build" : 10, + "url" : "/wastesequestration", + "water_to_build" : 10 + }, + "Lacuna::DB::Result::Building::Waste::Treatment" : { + "energy_to_build" : 95, + "food_to_build" : 75, + "image" : "wastetreatment", + "name" : "Waste Treatment Center", + "ore_to_build" : 83, + "url" : "/wastetreatment", + "water_to_build" : 95 + }, + "Lacuna::DB::Result::Building::Water::AtmosphericEvaporator" : { + "energy_to_build" : 560, + "food_to_build" : 630, + "image" : "atmosphericevaporator", + "name" : "Atmospheric Evaporator", + "ore_to_build" : 770, + "url" : "/atmosphericevaporator", + "water_to_build" : 70 + }, + "Lacuna::DB::Result::Building::Water::Production" : { + "energy_to_build" : 300, + "food_to_build" : 120, + "image" : "waterproduction", + "name" : "Water Production Plant", + "ore_to_build" : 350, + "url" : "/waterproduction", + "water_to_build" : 130 + }, + "Lacuna::DB::Result::Building::Water::Purification" : { + "energy_to_build" : 80, + "food_to_build" : 90, + "image" : "waterpurification", + "name" : "Water Purification Plant", + "ore_to_build" : 110, + "url" : "/waterpurification", + "water_to_build" : 10 + }, + "Lacuna::DB::Result::Building::Water::Reclamation" : { + "energy_to_build" : 191, + "food_to_build" : 175, + "image" : "waterreclamation", + "name" : "Water Reclamation Facility", + "ore_to_build" : 175, + "url" : "/waterreclamation", + "water_to_build" : 175 + }, + "Lacuna::DB::Result::Building::Water::Storage" : { + "energy_to_build" : 35, + "food_to_build" : 35, + "image" : "waterstorage", + "name" : "Water Storage Tank", + "ore_to_build" : 35, + "url" : "/waterstorage", + "water_to_build" : 35 + } +} diff --git a/lacuna/test/controllers/body_controller_test.rb b/lacuna/test/controllers/body_controller_test.rb index c6eea704..72c8dc56 100644 --- a/lacuna/test/controllers/body_controller_test.rb +++ b/lacuna/test/controllers/body_controller_test.rb @@ -42,4 +42,103 @@ class BodyControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_matches_golden('get_status/body_status_planet_owner_golden.json', response.body) end + + test 'get_buildings requires a session' do + post '/body/get_buildings', params: { body_id: BODY_ID } + assert_response :unauthorized + end + + test 'get_buildings 404s for a body the caller cannot access' do + stranger = Empire.create!( + name: 'RailsParityStranger2', stage: 'founded', date_created: Time.now.utc, + self_destruct_date: Time.now.utc, sitter_password: 'x', status_message: 'x', + password: 'x', last_login: Time.now.utc, storage_boost: Time.now.utc, food_boost: Time.now.utc, + water_boost: Time.now.utc, ore_boost: Time.now.utc, energy_boost: Time.now.utc, + happiness_boost: Time.now.utc, building_boost: Time.now.utc, spy_training_boost: Time.now.utc, + outlaw_date: Time.now.utc, + ) + id = write_test_session(empire_id: stranger.id, is_sitter: 0) + + post '/body/get_buildings', params: { body_id: BODY_ID }, + headers: { 'Authorization' => "Token #{id}" } + + assert_response :not_found + ensure + stranger&.destroy + end + + # No live-Perl golden capture here (unlike the other endpoints in this file): the shared dev + # DB's `building` row id 14382 still has an invalid `class` ("...::Apartment", which doesn't + # exist in lib/ — see test/fixtures/building.yml's header comment) that Perl's DynamicSubclass + # component can't instantiate, so the live Perl server 500s on this body's get_buildings today. + # This is a structural/wiring test against the (corrected) fixture data instead. + test 'get_buildings returns per-building summaries with the right conditional keys' do + empire = empire(:rails_parity_owner) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + + post '/body/get_buildings', params: { body_id: BODY_ID }, + headers: { 'Authorization' => "Token #{id}" } + + assert_response :success + json = response.parsed_body + assert_equal({ 'surface_image' => 'surface-p7' }, json['body']) + + buildings = json['buildings'] + assert_equal %w[8911 14381 14382].sort, buildings.keys.sort + + crater = buildings['8911'] + assert_equal( + { 'url' => '/crater', 'image' => 'crater1', 'name' => 'Crater', 'x' => 1, 'y' => -5, + 'level' => 1, 'efficiency' => 100 }, crater + ) + assert_not crater.key?('pending_build') + assert_not crater.key?('work') + assert_not crater.key?('repair_costs') + + development = buildings['14381'] + assert_equal( + { 'url' => '/development', 'image' => 'devel5', 'name' => 'Development Ministry', 'x' => 0, + 'y' => 0, 'level' => 5, 'efficiency' => 100 }, development + ) + assert_not development.key?('pending_build') + assert_not development.key?('work') + assert_not development.key?('repair_costs') + + luxury_housing = buildings['14382'] + assert_equal '/luxuryhousing', luxury_housing['url'] + assert_equal 'luxuryhousing3', luxury_housing['image'] + assert_equal 'Luxury Housing', luxury_housing['name'] + assert_equal 80, luxury_housing['efficiency'] + + assert luxury_housing.key?('pending_build') + assert_equal LacunaDateFormat.format(Building.find(14382).upgrade_started), + luxury_housing['pending_build']['start'] + assert_equal LacunaDateFormat.format(Building.find(14382).upgrade_ends), + luxury_housing['pending_build']['end'] + + assert luxury_housing.key?('work') + assert_equal 0, luxury_housing['work']['seconds_remaining'] # fixture's timers are in the past + assert_equal LacunaDateFormat.format(Building.find(14382).work_started), + luxury_housing['work']['start'] + assert_equal LacunaDateFormat.format(Building.find(14382).work_ends), + luxury_housing['work']['end'] + + assert luxury_housing.key?('repair_costs') + assert_equal %w[energy food ore water].sort, luxury_housing['repair_costs'].keys.sort + luxury_housing['repair_costs'].each_value { |v| assert v.is_a?(Integer) && v.positive? } + end + + test 'get_buildings clears needs_surface_refresh as a side effect' do + empire = empire(:rails_parity_owner) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + Body.find(BODY_ID).update!(needs_surface_refresh: true) + + post '/body/get_buildings', params: { body_id: BODY_ID }, + headers: { 'Authorization' => "Token #{id}" } + + assert_response :success + assert_not Body.find(BODY_ID).needs_surface_refresh? + ensure + Body.find(BODY_ID).update!(needs_surface_refresh: false) + end end diff --git a/lacuna/test/controllers/empire_controller_test.rb b/lacuna/test/controllers/empire_controller_test.rb index 2a3a0085..f57931e4 100644 --- a/lacuna/test/controllers/empire_controller_test.rb +++ b/lacuna/test/controllers/empire_controller_test.rb @@ -50,4 +50,116 @@ class EmpireControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_matches_golden('get_status/empire_status_golden.json', response.body) end + + test 'view_boosts matches the golden Perl response' do + empire = empire(:rails_parity_owner) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + + post '/empire/view_boosts', headers: { 'Authorization' => "Token #{id}" } + + assert_response :success + assert_matches_golden('view_boosts/empire_view_boosts_golden.json', response.body, + ignore_paths: [%w[status server time], %w[status empire rpc_count]]) + end + + test 'boost_ore requires a session' do + post '/empire/boost_ore' + assert_response :unauthorized + end + + test 'boost_ore defaults weeks to 1, spends 5 essentia, and extends the boost column' do + empire = empire(:rails_parity_owner) + empire.update!(essentia_free: 20.0) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + + post '/empire/boost_ore', headers: { 'Authorization' => "Token #{id}" } + + assert_response :success + empire.reload + assert_in_delta 15.0, empire.essentia_free, 0.001 + assert_in_delta (Time.now.utc + 7.days).to_i, empire.ore_boost.to_i, 5 + + # `ActionDispatch::IntegrationTest` already defines its own `body` method, shadowing the + # `body(:label)` fixture accessor — use the known fixture id directly (see + # test/fixtures/files/get_status/README.md's sibling gotcha note for BodyController). + home = Body.find(14954) + assert home.needs_recalc? + assert home.boost_enabled? + + log = EssentiaLog.where(empire_id: empire.id).order(:id).last + assert_equal(-5.0, log.amount) + assert_equal 'ore_boost boost', log.description + assert_equal 'tester', log.api_key + + body_json = response.parsed_body + assert_equal LacunaDateFormat.format(empire.ore_boost), body_json['boosts']['ore'] + end + + test 'boost_water accepts an explicit weeks and stacks onto an existing future expiry' do + empire = empire(:rails_parity_owner) + empire.update!(essentia_free: 100.0, water_boost: 1.week.from_now) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + original_expiry = empire.water_boost + + post '/empire/boost_water', params: { weeks: 2 }, headers: { 'Authorization' => "Token #{id}" } + + assert_response :success + empire.reload + assert_in_delta 90.0, empire.essentia_free, 0.001 + assert_in_delta (original_expiry + 14.days).to_i, empire.water_boost.to_i, 5 + end + + test 'boost_energy rejects a negative or non-integer weeks with no side effects' do + empire = empire(:rails_parity_owner) + empire.update!(essentia_free: 20.0) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + + post '/empire/boost_energy', params: { weeks: -1 }, headers: { 'Authorization' => "Token #{id}" } + assert_response :unprocessable_entity + + post '/empire/boost_energy', params: { weeks: 1.5 }, headers: { 'Authorization' => "Token #{id}" } + assert_response :unprocessable_entity + + empire.reload + assert_in_delta 20.0, empire.essentia_free, 0.001 + assert_not EssentiaLog.exists?(empire_id: empire.id, description: 'energy_boost boost') + end + + test 'boost_energy allows weeks: 0 as a legal no-op purchase' do + empire = empire(:rails_parity_owner) + empire.update!(essentia_free: 20.0) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + + post '/empire/boost_energy', params: { weeks: 0 }, headers: { 'Authorization' => "Token #{id}" } + + assert_response :success + empire.reload + assert_in_delta 20.0, empire.essentia_free, 0.001 + end + + test 'boost_food returns 402 when essentia is insufficient, with no side effects' do + empire = empire(:rails_parity_owner) + empire.update!(essentia_free: 0.0, essentia_game: 0.0, essentia_paid: 0.0) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + + post '/empire/boost_food', headers: { 'Authorization' => "Token #{id}" } + + assert_response :payment_required + assert_equal 'Not enough essentia.', response.parsed_body['error'] + assert_not EssentiaLog.exists?(empire_id: empire.id, description: 'food_boost boost') + end + + test 'boost_storage cascades the essentia spend across free, game, and paid buckets' do + empire = empire(:rails_parity_owner) + empire.update!(essentia_free: 2.0, essentia_game: 1.0, essentia_paid: 10.0) + id = write_test_session(empire_id: empire.id, is_sitter: 0) + + post '/empire/boost_storage', params: { weeks: 1 }, headers: { 'Authorization' => "Token #{id}" } + + assert_response :success + empire.reload + assert_in_delta 0.0, empire.essentia_free, 0.001 + assert_in_delta 0.0, empire.essentia_game, 0.001 + assert_in_delta 8.0, empire.essentia_paid, 0.001 + end end diff --git a/lacuna/test/fixtures/building.yml b/lacuna/test/fixtures/building.yml index 363e3f30..03bc5cc7 100644 --- a/lacuna/test/fixtures/building.yml +++ b/lacuna/test/fixtures/building.yml @@ -1,7 +1,16 @@ # Captured from the real buildings on test/fixtures/body.yml's body (id 14954) — see # test/fixtures/files/get_status/README.md. The Permanent::Crater exists on every generated -# body from map creation; Development and Apartment were added to exercise +# body from map creation; Development and LuxuryHousing were added to exercise # population/building_count/build_queue_size/build_queue_length. +# +# luxury_housing (id 14382) was originally seeded with class +# "Lacuna::DB::Result::Building::Apartment", which does not exist anywhere in lib/ — not a real +# Perl class, so it could not have been captured from a live server. Corrected here to +# LuxuryHousing (a real, thematically close class) with efficiency dropped to 80 so this same +# row also exercises get_buildings's `repair_costs` conditional key. NOTE: the live dev DB's +# `building` table (id 14382) still has the original bogus class/efficiency=100 — fix that row +# too (or point a golden capture at a different building) before capturing a get_buildings +# golden JSON against the live Perl server, since a golden capture must reflect real DB state. permanent_crater: id: 8911 class: Lacuna::DB::Result::Building::Permanent::Crater @@ -36,11 +45,11 @@ development: y: 0 body_id: 14954 -apartment: +luxury_housing: id: 14382 - class: Lacuna::DB::Result::Building::Apartment + class: Lacuna::DB::Result::Building::LuxuryHousing level: 3 - efficiency: 100 + efficiency: 80 is_upgrading: 1 is_working: 1 date_created: 2026-08-16 14:02:26 diff --git a/lacuna/test/fixtures/files/view_boosts/README.md b/lacuna/test/fixtures/files/view_boosts/README.md new file mode 100644 index 00000000..12a0c199 --- /dev/null +++ b/lacuna/test/fixtures/files/view_boosts/README.md @@ -0,0 +1,21 @@ +# view_boosts golden fixture + +`empire_view_boosts_golden.json` is a frozen snapshot of a real `Lacuna::RPC::Empire#view_boosts` +response, captured by hitting a live Perl server directly: + +``` +curl -s -X POST http://localhost:3050/empire -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"view_boosts","params":[""]}' +``` + +Only the `result` payload is kept (the `jsonrpc`/`id` envelope is stripped). + +Captured against the same "RailsParityOwner" test empire (id 6) used by the `get_status` +goldens — see `../get_status/README.md` for how that empire/fixture set was created. No new +fixture rows were needed: `view_boosts` reads straight off `empire.yml`'s eight `*_boost` +columns plus the same `Empire#get_status` payload the `get_status` golden already pins down. + +Unlike this directory's sibling, this response nests `server`/`empire` one level deeper (under +`status`), so the test passes explicit `ignore_paths: [%w[status server time], %w[status empire +rpc_count]]` to `assert_matches_golden` rather than relying on `DEFAULT_IGNORE_PATHS`, which is +anchored at the top level. diff --git a/lacuna/test/fixtures/files/view_boosts/empire_view_boosts_golden.json b/lacuna/test/fixtures/files/view_boosts/empire_view_boosts_golden.json new file mode 100644 index 00000000..0e20f1b2 --- /dev/null +++ b/lacuna/test/fixtures/files/view_boosts/empire_view_boosts_golden.json @@ -0,0 +1,58 @@ +{ + "boosts": { + "happiness": "16 08 2026 13:58:50 +0000", + "storage": "16 08 2026 13:58:50 +0000", + "spy_training": "16 08 2026 13:58:50 +0000", + "energy": "16 08 2026 13:58:50 +0000", + "food": "16 08 2026 13:58:50 +0000", + "water": "16 08 2026 13:58:50 +0000", + "ore": "16 08 2026 13:58:50 +0000", + "building": "16 08 2026 13:58:50 +0000" + }, + "status": { + "server": { + "time": "23 08 2026 10:34:01 +0000", + "star_map_size": { "y": [-1500, 1500], "x": [-1500, 1500] }, + "rpc_limit": 10000, + "version": 4.0001 + }, + "empire": { + "next_colony_srcs": 100000, + "stations": {}, + "planets": { "14954": "Ow Friafae 3" }, + "status_message": "Rails parity test empire.", + "tech_level": 10, + "rpc_count": 1, + "home_planet_id": 14954, + "is_isolationist": 1, + "id": 6, + "next_colony_cost": 100000, + "insurrect_value": 100000, + "self_destruct_active": 0, + "next_station_cost": 10000000000000000000, + "latest_message_id": 205, + "has_new_messages": 1, + "bodies": { + "colonies": [ + { + "zone": "0|0", + "star_id": 2081, + "empire_id": 6, + "empire_name": "RailsParityOwner", + "star_name": "Ow Friafae", + "orbit": 3, + "id": 14954, + "y": 63, + "type": "p7", + "x": 102, + "name": "Ow Friafae 3" + } + ] + }, + "self_destruct_date": "16 08 2026 13:58:50 +0000", + "name": "RailsParityOwner", + "essentia": 0, + "colonies": { "14954": "Ow Friafae 3" } + } + } +} diff --git a/lacuna/test/models/building_test.rb b/lacuna/test/models/building_test.rb new file mode 100644 index 00000000..eab71f10 --- /dev/null +++ b/lacuna/test/models/building_test.rb @@ -0,0 +1,71 @@ +require_relative '../test_helper' + +class BuildingTest < ActiveSupport::TestCase + test 'image_level clamps to a max suffix of 9' do + b = building(:development) # level 5 + assert_equal 'devel5', b.image_level + + b.level = 12 + assert_equal 'devel9', b.image_level + end + + test 'current_level_cost mirrors INFLATION ** (level - 1)' do + b = building(:development) + assert_in_delta Empire::INFLATION**(b.level - 1), b.current_level_cost, 0.0001 + end + + test 'upgrade_status is nil when not upgrading' do + assert_nil building(:development).upgrade_status + end + + test 'upgrade_status reports the timer when upgrading' do + b = building(:luxury_housing) + status = b.upgrade_status + assert_equal LacunaDateFormat.format(b.upgrade_started), status[:start] + assert_equal LacunaDateFormat.format(b.upgrade_ends), status[:end] + assert_equal b.upgrade_ends.to_i - Time.now.utc.to_i, status[:seconds_remaining] + end + + test 'work_seconds_remaining is 0 when not working or when the timer has already elapsed' do + assert_equal 0, building(:development).work_seconds_remaining + assert_equal 0, building(:luxury_housing).work_seconds_remaining # fixture's work_ends is in the past + end + + # Independently re-derives the expected damage cost from the building-class-info JSON directly + # (rather than calling #current_level_cost), so this catches a wrong-column mapping bug + # (e.g. ore/water swapped) in #repair_costs, not just a formula typo shared by both. + test 'repair_costs scales each build-cost constant by the damage fraction' do + b = building(:luxury_housing) # efficiency: 80, level: 3 + class_info = Building::CLASS_INFO.fetch(b.perl_class) + + level_cost = Empire::INFLATION**(b.level - 1) + damage_cost = level_cost * (100 - b.efficiency) / 100.0 + + expected = { + ore: (class_info['ore_to_build'] * damage_cost).round, + water: (class_info['water_to_build'] * damage_cost).round, + food: (class_info['food_to_build'] * damage_cost).round, + energy: (class_info['energy_to_build'] * damage_cost).round, + } + + assert_equal expected, b.repair_costs + end + + test 'summary omits pending_build/work/repair_costs when not applicable' do + b = building(:development) # efficiency 100, not upgrading, not working + summary = b.summary + + assert_not summary.key?(:pending_build) + assert_not summary.key?(:work) + assert_not summary.key?(:repair_costs) + end + + test 'summary includes pending_build/work/repair_costs when applicable' do + b = building(:luxury_housing) + summary = b.summary + + assert summary.key?(:pending_build) + assert summary.key?(:work) + assert summary.key?(:repair_costs) + end +end