Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions app/models/floor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# A FLOOR: A PLACE ITS WORLD GIVES A ROOM COUNT, laid out by the engine as
# exactly that many rooms on one storey (the engine's `floor.rs`), with a way
# down rolled onto a room that is never the entry. Stage 2 of the dungeon
# plan; a place with no row is laid out by `Location::Interior` as it always
# was, so every stored story plays as before.
#
# LAID OUT ONCE, THE FIRST TIME ANYBODY REACHES IT, and then left alone -- VISION
# principle 7. Every later game in the world reads the stored rooms. So this
# row is a PARAMETER OF THE LAYOUT and not a description of it: a re-seed that
# changes `rooms` after the floor has rooms changes the row only, and
# `rake game:doctor` reports the difference (`floor_laid_out_to_another_count`)
# rather than anything re-laying the floor.
#
# A WORLD SUPPLIES PARAMETERS, NEVER BEHAVIOUR (AGENTS.md). `danger` and
# `hazard` are words of `Location::Parameters`' own tables, the ones a place
# call picks from, and what each does to a room is that table's; `down_location`
# names the floor below, whose doorway the engine opens onto the way-down room.
# The model's picks for a floor are never read.
#
# WHO WRITES IT: `WorldSeed::Loader`, from a location's `floor:` key, and
# nothing else. `WorldSeed::Exporter` writes the key back.
class Floor < ApplicationRecord
# THE MOST ROOMS A FLOOR IS LAID OUT WITH, the engine's `floor::MOST_ROOMS`.
MOST_ROOMS = 1000

# THE PACES A SIDE OF A FLOOR'S ROOM IS GIVEN ON AVERAGE when its footprint
# is sized from its room count, the engine's `floor::ROOM_SIDE`.
ROOM_SIDE = 6

belongs_to :location
belongs_to :down_location, class_name: "Location", optional: true

validates :location_id, uniqueness: true
validates :rooms, numericality: { only_integer: true, in: 1..MOST_ROOMS }
validates :rooms, numericality: { greater_than_or_equal_to: 2, message: "must be at least 2 with a way down, which is never the entry" },
if: :down_location_id?
validates :danger, inclusion: { in: Location::Parameters::DANGER.keys }, allow_nil: true
validates :hazard, inclusion: { in: Location::Parameters::HAZARDS }, allow_nil: true
validate :a_way_down_is_another_floor_of_this_story

# HOW MANY ROOMS A FLOOR OF `rooms` IS DRAWN WITH: at least one, at least two
# with a way down, and at most `MOST_ROOMS` -- the engine's
# `floor::room_count`, so a count outside the validation above (a row raw SQL
# wrote) is still read as the engine reads it.
def self.room_count(rooms, down: false) = rooms.to_i.clamp(down ? 2 : 1, MOST_ROOMS)

# `[columns, rows]` OF THE GRID A FLOOR OF `rooms` ROOMS IS CUT INTO: as square
# as it goes, the last line short when the count is not a product. The
# engine's `floor::grid`.
def self.grid(rooms)
rooms = [ rooms, 1 ].max
columns = Integer.sqrt(rooms)
columns += 1 if columns * columns < rooms
[ columns, rooms.ceildiv(columns) ]
end

# THE FOOTPRINT `[width, depth]` A FLOOR OF `rooms` ROOMS IS SIZED AT, which
# `WorldSeed::Loader` writes so the floor is a place (`Location#place?`)
# before anybody walks into it. The engine's `floor::footprint`.
def self.footprint(rooms) = grid(rooms).map { |side| side * ROOM_SIDE }

# The count this floor is laid out to, as the engine reads it.
def room_count = self.class.room_count(rooms, down: down_location_id.present?)

def footprint = self.class.footprint(room_count)

private

def a_way_down_is_another_floor_of_this_story
return if down_location.nil?

if down_location_id == location_id
errors.add(:down_location, "is this floor itself")
elsif location && down_location.story_id != location.story_id
errors.add(:down_location, "is in another story")
elsif down_location.floor.nil?
errors.add(:down_location, "is not a floor")
end
end
end
17 changes: 16 additions & 1 deletion app/models/location.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ class Location < ApplicationRecord
# And every game's record of having stood here (`Playthrough::Visit`), for
# the same reason.
has_many :visits, class_name: "Playthrough::Visit", dependent: :destroy, inverse_of: :location
# WHETHER THIS PLACE IS A FLOOR, and what it is laid out to (`Floor`). Goes
# with the place, which the foreign key's cascade says too.
has_one :floor, dependent: :destroy, inverse_of: :location
has_and_belongs_to_many :connected_locations,
class_name: "Location",
join_table: "location_connections",
Expand Down Expand Up @@ -230,7 +233,13 @@ class Location < ApplicationRecord
# one of `Location::Kind`'s words or nothing at all -- `population`'s shape
# above and its reason: nil means NOBODY PICKED, and it is a state rather
# than a missing value. See `Location::Kind`'s header and the migration's.
validates :kind, inclusion: { in: Location::Kind::KINDS }, allow_nil: true
#
# A FLOOR MAY ALSO CARRY A BUILDING'S WORD (`Location::Kind::BUILDINGS`),
# because nobody stands in a floor: its word deals its rooms their sorts as
# the engine lays them out (`Floor`), which is the place call's `place_kind`
# written down. The floor is only asked about for a word that needs it.
validates :kind, inclusion: { in: Location::Kind::KINDS + Location::Kind::BUILDINGS }, allow_nil: true
validate :a_building_word_is_a_floors
validates :density, inclusion: { in: Location::Kind::DENSITIES }, allow_nil: true

validates :name, presence: true
Expand Down Expand Up @@ -504,6 +513,12 @@ def mark_protagonist_visit!(at)

private

def a_building_word_is_a_floors
return if kind.nil? || Location::Kind::KINDS.include?(kind) || !Location::Kind::BUILDINGS.include?(kind) || floor

errors.add(:kind, "is a building's word, which only a floor carries")
end

# See `validate :a_box_is_whole` above for why this is one rule over five
# columns rather than five rules, and `Location::Box.shape` for the three
# whole answers it is holding this row to.
Expand Down
62 changes: 55 additions & 7 deletions app/models/location/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,16 @@ def detail_schema
# method a model picked for "the way to The Rusted Anchor" are the distance
# and travel method of the way to its taproom.
#
# AND SO IS EVERYTHING ELSE THE DOORWAY WAS, side by side
# (`#doorway_side`): its barrier and its key on both rows, its hazard on the
# row that had one -- so a one-way drop keeps its direction -- and every
# game's opening through it (`Playthrough::Passage`, re-made on the new row
# with its id kept) and every toll paid crossing it (`Playthrough::Toll`,
# pointed at the new row). Before this, a locked door onto a building came
# out of the layout unlocked for everybody, and a toll on the old row made
# its delete fail its foreign key. The engine's `open_the_way_in` is the same
# move, and renderedstep/engine#94 made it.
#
# A DOOR IS TWO ROWS, so the pair is dropped and the pair is rewritten, and
# #connect! is what writes them -- the same writer, the same direction-neutral
# values, no second spelling of what a doorway is.
Expand All @@ -267,23 +277,56 @@ def detail_schema
# a party standing in a container, its rooms reachable from nowhere. A
# building nothing can reach is `Story::Doctor`'s to report
# (`place_reachable_only_from_inside`) and the neighbour keeps every other way
# out it had.
# out it had. Its tolls are left naming no doorway, as a demolished one's are.
#
# A FLOOR'S WAY DOWN IS NOT ROUTED HERE: this generator never lays a floor
# out (the engine does, `Floor`), so it has no way-down room to open one onto.
def open_the_way_in!
doorstep = Location::Interior.doorstep(location)
return if doorstep.empty?

ways_in.each do |neighbour, attributes|
LocationConnection.where(location: location, connected_location: neighbour).delete_all
LocationConnection.where(location: neighbour, connected_location: location).delete_all
sides = [ doorway_side(location, neighbour), doorway_side(neighbour, location) ]
Playthrough::Toll.where(id: sides.flat_map { |side| side[:tolls] }).update_all(location_connection_id: nil)
doomed = LocationConnection.where(location: location, connected_location: neighbour)
.or(LocationConnection.where(location: neighbour, connected_location: location))
Playthrough::Passage.where(location_connection: doomed).delete_all
doomed.delete_all

room = doorstep.find { |candidate| room_for_a_way_in?(candidate) }
next if room.nil?

connect!(room, neighbour, attributes)
connect!(neighbour, room, attributes)
[ [ room, neighbour, sides.first ], [ neighbour, room, sides.last ] ].each do |from, to, side|
written = connect!(from, to, attributes, carried: side[:values]) or next

side[:passages].each { |passage| Playthrough::Passage.create!(passage.merge("location_connection_id" => written.id)) }
Playthrough::Toll.where(id: side[:tolls]).update_all(location_connection_id: written.id)
end
end
end

# WHAT A MOVED DOORWAY KEEPS ON ITS SIDE FROM `from` TO `to`. Read before
# anything is deleted. `values` are the columns the new row is written with:
# the barrier and the key, and the hazard only where this side had the row --
# a side never written takes the way back's barrier and key and no hazard,
# since a hazard is paid in one direction. `passages` are every game's
# openings of this side, id included, and `tolls` the ids of every toll paid
# crossing it.
PASSAGE_COLUMNS = %w[id playthrough_id means opened_at opened_by_item_id created_at updated_at].freeze

def doorway_side(from, to)
own = LocationConnection.find_by(location: from, connected_location: to)
source = own || LocationConnection.find_by(location: to, connected_location: from)
return { values: {}, passages: [], tolls: [] } if source.nil?

values = { "barrier" => source.barrier, "key_template_id" => source.key_template_id }
return { values: values, passages: [], tolls: [] } if own.nil?

{ values: values.merge("hazard" => own.hazard, "hazard_die" => own.hazard_die),
passages: own.passages.order(:id).map { |passage| passage.attributes.slice(*PASSAGE_COLUMNS) },
tolls: own.tolls.order(:id).ids }
end

# EVERY DOORWAY THIS PLACE ARRIVED WITH, as the far end and the label to carry
# over. Read before anything is deleted, and once, because #open_the_way_in!
# writes as it goes. A door is two rows and either of them may be the one that
Expand Down Expand Up @@ -1583,17 +1626,22 @@ def connected?(neighbour)
# not keep a paid answer through. Anything the save itself raises is left as
# itself: a uniqueness collision written by the far side's own generator is
# not a bad label and does not cost this room its answer.
def connect!(from, to, attributes)
#
# `carried` is what a moved doorway keeps (`#doorway_side`); the row written
# is returned, or nil when it was already there.
def connect!(from, to, attributes, carried: {})
return if LocationConnection.exists?(location: from, connected_location: to)

edge = LocationConnection.new(
location: from,
connected_location: to,
distance: sanitize_string(attributes["distance"]),
travel_method: sanitize_string(attributes["travel_method"])
travel_method: sanitize_string(attributes["travel_method"]),
**carried.symbolize_keys
)
raise UnusableExitLabel.new(edge) unless edge.valid?

edge.save!
edge
end
end
10 changes: 9 additions & 1 deletion app/models/location/one_room_fold.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ class Location::OneRoomFold
[ Playthrough::Visit, :location_id ],
[ Playthrough::Volition::Record, :location_id ],
[ LocationConnection, :location_id ],
[ LocationConnection, :connected_location_id ]
[ LocationConnection, :connected_location_id ],
# Never a room's: a floors row names a floor and the floor below it, and a
# floor is never folded (`#reasons`). Moved anyway, so nothing is left
# naming the deleted row whatever a database carries.
[ Floor, :location_id ],
[ Floor, :down_location_id ]
].freeze

# What somebody may have put IN the place row itself. Any of it makes the
Expand Down Expand Up @@ -108,6 +113,9 @@ def proven? = reasons.empty?
def reasons
@reasons ||= [
("#{place.name} carries no footprint of its own" unless place.place?),
# A floor of one room is laid out so on purpose (`Floor`), and is left
# alone once it is (VISION principle 7).
("#{place.name} is a floor" if place.floor),
("#{room.name} does not fill the whole of #{place.name}" unless fills_the_place?),
("#{room.name} has rooms inside it" if room.child_locations.exists?),
("#{room.name} was never written out" unless room.realized?),
Expand Down
4 changes: 4 additions & 0 deletions app/models/playthrough/sqlite_package.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ def extract_bundle
"characters" => rows_by_id(connection, "characters", character_ids),
"items" => rows_by_id(connection, "items", template_ids + playthrough_item_ids),
"location_connections" => location_connection_rows(connection, location_ids),
# A FLOOR GOES WITH THE PLACE IT IS, and its way down is one of the
# story's own places, so every row it names is in the package.
"floors" => rows_where(connection, "floors", "location_id", location_ids),
"quests" => rows_by_id(connection, "quests", quest_ids),
"quest_steps" => rows_where(connection, "quest_steps", "quest_id", quest_ids),
"quest_outcomes" => rows_where(connection, "quest_outcomes", "quest_id", quest_ids),
Expand Down Expand Up @@ -338,6 +341,7 @@ def materialize!(path, bundle)
insert_table(connection, "characters", bundle["characters"])
insert_table(connection, "items", bundle["items"])
insert_table(connection, "location_connections", bundle["location_connections"])
insert_table(connection, "floors", bundle["floors"])
insert_table(connection, "quests", bundle["quests"])
insert_table(connection, "quest_steps", bundle["quest_steps"])
insert_table(connection, "quest_outcomes", bundle["quest_outcomes"])
Expand Down
76 changes: 76 additions & 0 deletions app/models/story/doctor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def findings
*hostility,
*hazards,
*geometry,
*floors,
*vitals_rows,
*arc,
*event_stream,
Expand Down Expand Up @@ -3104,6 +3105,81 @@ def worn_things
"this story has things that can be worn, and they could not be checked: #{e.message}", :manual) ]
end

# ------------------------------------------------------------------------
# A FLOOR THE ENGINE WOULD NOT HAVE LAID OUT AS IT STANDS (`Floor`). A floor
# is laid out once, the first time anybody walks in, as exactly its count of
# rooms on storey 0 inside its footprint, with its way down on a room that is
# never the entry -- and then left alone. So each of these is reported and
# never repaired: laying a floor out again would rewrite rooms every game in
# the world has walked (VISION principle 7).
#
# floor_laid_out_to_another_count rooms on record differ from its count
# floor_going_down_to_no_floor its way down names a place with no row
# floor_with_a_hand_drawn_room a room the engine's layout cannot have
# drawn: off storey 0, unplaced, or
# outside the floor's footprint
# floor_with_no_way_down no room of it leads to its way down
# floor_way_down_on_its_entry its entry room leads there
def floors
Floor.where(location: story.locations).includes(:location, down_location: :floor).order(:id).flat_map do |floor|
place = floor.location
rooms = place.child_locations.order(:id).to_a
[ *floor_going_down_to_no_floor(floor), *(rooms.any? ? laid_out_floor(floor, place, rooms) : []) ]
end
end

def floor_going_down_to_no_floor(floor)
down = floor.down_location
return [] if down.nil? || down.floor

[ finding(:floor_going_down_to_no_floor, :warning,
"#{floor.location.name} is a floor whose way down is #{down.name}, which is not a floor -- a way " \
"down leads to the floor below, and the world file that wrote it would not load",
:manual, subject: floor.location) ]
end

# A re-seed may change a laid-out floor's count (`WorldSeed::Loader#load_floors!`),
# and the rooms stay as they were laid out; this is that difference said.
def laid_out_floor(floor, place, rooms)
findings = []
if rooms.size != floor.room_count
findings << finding(:floor_laid_out_to_another_count, :warning,
"#{place.name} is a floor of #{floor.room_count} room(s) and was laid out with #{rooms.size} -- " \
"a floor is laid out once and never again, so its count changed after it was laid out",
:manual, subject: place)
end

drawn = rooms.reject { |room| room.placed? && room.z.zero? && place.interior? && room.box.inside_footprint?(place.width, place.depth) }
if drawn.any?
findings << finding(:floor_with_a_hand_drawn_room, :warning,
"#{drawn.map(&:name).join(", ")} #{drawn.one? ? "is" : "are"} inside #{place.name}, a floor, " \
"and not on its storey 0 inside its #{place.width}x#{place.depth} footprint -- the engine " \
"lays a floor's rooms out, and it did not lay #{drawn.one? ? "this one" : "these"} out",
:manual, subject: place)
end

findings.concat(floor_way_down(floor, place, rooms))
end

def floor_way_down(floor, place, rooms)
down = floor.down_location
return [] if down.nil?

below = [ down.id, *down.child_locations.ids ]
leading = rooms.select { |room| LocationConnection.exists?(location: room, connected_location_id: below) }
if leading.empty?
return [ finding(:floor_with_no_way_down, :warning,
"#{place.name} is a floor whose way down is #{down.name}, and none of its rooms leads there",
:manual, subject: place) ]
end
return [] unless leading.include?(rooms.first)

[ finding(:floor_way_down_on_its_entry, :warning,
"#{place.name}'s entry room #{rooms.first.name} leads down to #{down.name} -- a floor's way down is " \
"never on its entry",
:manual, subject: place) ]
end

# ------------------------------------------------------------------------
# A PLACE WITH NO LAYOUT: neither a box nor a footprint, so nobody can be
# given a spot in it and what is within arm's length there cannot be
Expand Down
26 changes: 26 additions & 0 deletions db/migrate/20261030200000_create_floors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# A FLOOR: a place its world lays out as exactly `rooms` rooms on one storey,
# the first time anybody reaches it, and then leaves alone (VISION.md
# principle 7). A world file names one with `floor:` (`WorldSeed::Loader`);
# where a place has no row it is laid out as it always was, so every stored
# story plays as before. `danger` and `hazard` are the floor's own words,
# which every room of it is dealt from; `down_location` is the floor below,
# whose doorway the engine opens onto a room that is never the entry.
#
# THE VERSION AND THE DECLARATION ARE THE ENGINE'S, not ones `bin/rails
# generate migration` stamped. renderedstep/engine#94 applied this migration
# to every world dump in its `parity/worlds/`, version row included, and its
# store holds every fact of the table as declared here
# (`store::OPTIONAL_SHAPES`): a different version or declaration would make
# those dumps disagree with a database this repository migrates.
class CreateFloors < ActiveRecord::Migration[8.1]
def change
create_table :floors do |t|
t.references :location, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade }
t.integer :rooms, null: false
t.string :danger
t.string :hazard
t.references :down_location, foreign_key: { to_table: :locations, on_delete: :nullify }
t.timestamps
end
end
end
Loading
Loading