diff --git a/app/models/floor.rb b/app/models/floor.rb new file mode 100644 index 00000000..fb9cf6a3 --- /dev/null +++ b/app/models/floor.rb @@ -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 diff --git a/app/models/location.rb b/app/models/location.rb index 84356ebe..16a1b6cd 100644 --- a/app/models/location.rb +++ b/app/models/location.rb @@ -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", @@ -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 @@ -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. diff --git a/app/models/location/generator.rb b/app/models/location/generator.rb index 6ba46636..62b0b78b 100644 --- a/app/models/location/generator.rb +++ b/app/models/location/generator.rb @@ -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. @@ -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 @@ -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 diff --git a/app/models/location/one_room_fold.rb b/app/models/location/one_room_fold.rb index e1be2199..7df5bd0a 100644 --- a/app/models/location/one_room_fold.rb +++ b/app/models/location/one_room_fold.rb @@ -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 @@ -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?), diff --git a/app/models/playthrough/sqlite_package.rb b/app/models/playthrough/sqlite_package.rb index 61458010..7de118f0 100644 --- a/app/models/playthrough/sqlite_package.rb +++ b/app/models/playthrough/sqlite_package.rb @@ -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), @@ -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"]) diff --git a/app/models/story/doctor.rb b/app/models/story/doctor.rb index ab9380d1..2b516573 100644 --- a/app/models/story/doctor.rb +++ b/app/models/story/doctor.rb @@ -76,6 +76,7 @@ def findings *hostility, *hazards, *geometry, + *floors, *vitals_rows, *arc, *event_stream, @@ -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 diff --git a/db/migrate/20261030200000_create_floors.rb b/db/migrate/20261030200000_create_floors.rb new file mode 100644 index 00000000..5a6cf17f --- /dev/null +++ b/db/migrate/20261030200000_create_floors.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 1e4e8d87..0cfa85f2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_30_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_30_200000) do create_table "characters", force: :cascade do |t| t.integer "story_id", null: false t.string "fullname" @@ -77,6 +77,18 @@ t.index ["ruby_llm_model_id"], name: "index_chats_on_ruby_llm_model_id" end + create_table "floors", force: :cascade do |t| + t.integer "location_id", null: false + t.integer "rooms", null: false + t.string "danger" + t.string "hazard" + t.integer "down_location_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["down_location_id"], name: "index_floors_on_down_location_id" + t.index ["location_id"], name: "index_floors_on_location_id", unique: true + end + create_table "interactions", force: :cascade do |t| t.integer "character_id", null: false t.integer "scene_id" @@ -877,6 +889,8 @@ add_foreign_key "chats", "players" add_foreign_key "chats", "playthroughs" add_foreign_key "chats", "ruby_llm_models" + add_foreign_key "floors", "locations", column: "down_location_id", on_delete: :nullify + add_foreign_key "floors", "locations", on_delete: :cascade add_foreign_key "interactions", "characters" add_foreign_key "interactions", "locations" add_foreign_key "interactions", "scenes" diff --git a/ext/renderedstep/Cargo.lock b/ext/renderedstep/Cargo.lock index 4429f7e8..44e98722 100644 --- a/ext/renderedstep/Cargo.lock +++ b/ext/renderedstep/Cargo.lock @@ -465,7 +465,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderedstep-engine" version = "0.1.0" -source = "git+https://github.com/renderedstep/engine?rev=44bd3ce6785813e63ee432051426c3a03ca68fca#44bd3ce6785813e63ee432051426c3a03ca68fca" +source = "git+https://github.com/renderedstep/engine?rev=3846a64597bf4ddeeca56786890e8ea6e51ed36f#3846a64597bf4ddeeca56786890e8ea6e51ed36f" dependencies = [ "regex", "rusqlite", diff --git a/ext/renderedstep/Cargo.toml b/ext/renderedstep/Cargo.toml index e6c0da6e..f91eeefa 100644 --- a/ext/renderedstep/Cargo.toml +++ b/ext/renderedstep/Cargo.toml @@ -18,7 +18,7 @@ serde_json = { version = "1", features = ["preserve_order"] } # The engine, pinned to one commit of its public repository. `bin/rails # engine:build` builds against exactly this; moving the pin is a change of # its own, with the parity gates run again (README, "The Rust engine"). -renderedstep-engine = { git = "https://github.com/renderedstep/engine", rev = "44bd3ce6785813e63ee432051426c3a03ca68fca" } +renderedstep-engine = { git = "https://github.com/renderedstep/engine", rev = "3846a64597bf4ddeeca56786890e8ea6e51ed36f" } [profile.release] # A panic must unwind so the engine's own catch_unwind can turn it into an diff --git a/lib/engine_sweep/invariants.rb b/lib/engine_sweep/invariants.rb index 67aece69..e11d2c62 100644 --- a/lib/engine_sweep/invariants.rb +++ b/lib/engine_sweep/invariants.rb @@ -693,14 +693,22 @@ def geometry_unmoved # `{ name => { the five columns, plus the parent's name } }` out of the file, # with nil for every key it does not write -- which is all six for every room - # in every checked-in world. + # in every checked-in world. A floor's footprint is the one the loader + # derives from its count (`Floor.footprint`), since the file writes none. def geometry_in_file @geometry_in_file ||= Array(seed["locations"]).to_h do |row| [ row["name"], no_geometry.merge(Location::Box::COLUMNS.to_h { |column| [ column, row[column] ] }) + .merge(floor_footprint(row["floor"])) .merge("parent" => row["parent"]) ] end end + def floor_footprint(floor) + return {} unless floor.is_a?(Hash) + + %w[width depth].zip(Floor.footprint(Floor.room_count(floor["rooms"], down: floor["down"].present?))).to_h + end + def geometry_on_record(room) Location::Box::COLUMNS.to_h { |column| [ column, room[column] ] } .merge("parent" => room.parent_location&.name) diff --git a/lib/engine_sweep/scripts/a-floor-laid-out-to-its-count.yml b/lib/engine_sweep/scripts/a-floor-laid-out-to-its-count.yml new file mode 100644 index 00000000..af42005c --- /dev/null +++ b/lib/engine_sweep/scripts/a-floor-laid-out-to-its-count.yml @@ -0,0 +1,214 @@ +# A FLOOR IS LAID OUT TO ITS OWN COUNT, ITS WAY DOWN OFF THE ENTRY AND STILL +# LOCKED, AND IT IS LAID OUT ONCE. +# +# The Upper Floor's `floor:` names four rooms and a way down to The Lower +# Floor, behind a stair locked with the stair key. Walking in lays it out +# (`floor::lay_out`, from `realize.rs` `lay_out`): exactly four rooms on one +# storey, whatever the place call's picks say (this one asks for a few rooms +# over five storeys, safe and flooded, and is not obeyed). The way down is +# rolled from rooms 2-4, never the entry, and the stair onto The Lower Floor is +# opened onto that room, keyed as the file wrote it. Walking every room shows +# the whole floor: room 4 leads nowhere new, so there is no fifth. +# +# A second game walks the same rooms: the layout is world rows written on the +# first entry and never laid out again (VISION principle 7). +story: Two Floors and a Key +steps: +- id: take-the-stair-key + type: "/take stair key" + why: the stair down from the upper floor is locked with it + expect: + location: The Landing (realized) + carrying: [stair key] +- id: into-the-upper-floor + type: "/move The Upper Floor" + why: the floor is laid out as it is first walked into, at its entry, and the way down is not on the entry + browser: + token: into-the-upper-floor + realizes: The Upper Floor + replies: + - purpose: location + content: + description: A level of black stone passages lit by nothing you can see. + lore: The first floor was cut by people nobody remembers. + place_kind: mansion + parameters: + inside: a few rooms + storeys_above: two storeys up + storeys_below: deep + danger: safe + gradient: worse the deeper you go + hazard: flooded + - purpose: location + content: + description: A low chamber at the foot of the stair. + lore: Delvers rest here before going on. + items: [] + people: [] + - purpose: arrival + unavailable: true + expect: + location: The Upper Floor room 1 (realized) + storey: 0 + exits: + - The Landing (realized) + - The Upper Floor room 2 (stub) + changed: true + refused: false +- id: the-way-down-is-off-room-two + type: "/move The Upper Floor room 2" + why: the stair onto the lower floor was moved onto the room rolled for the way down + browser: + token: room-2 + realizes: The Upper Floor room 2 + replies: + - purpose: location + content: + description: A bare chamber with a stair cut down into its floor. + lore: The stair down is older than the rest. + items: [] + people: [] + - purpose: arrival + unavailable: true + expect: + location: The Upper Floor room 2 (realized) + storey: 0 + exits: + - The Lower Floor (stub) + - The Upper Floor room 1 (realized) + - The Upper Floor room 3 (stub) +- id: room-three + type: "/move The Upper Floor room 3" + browser: + token: room-3 + realizes: The Upper Floor room 3 + replies: + - purpose: location + content: + description: A narrow chamber of dripping stone. + lore: Water finds its way down here. + items: [] + people: [] + - purpose: arrival + unavailable: true + expect: + location: The Upper Floor room 3 (realized) + exits: + - The Upper Floor room 2 (realized) + - The Upper Floor room 4 (stub) +- id: room-four-is-the-last + type: "/move The Upper Floor room 4" + why: four rooms and no more; the floor ends here + browser: + token: room-4 + realizes: The Upper Floor room 4 + replies: + - purpose: location + content: + description: A dead end of rough-cut stone. + lore: The cutters stopped here. + items: [] + people: [] + - purpose: arrival + unavailable: true + expect: + location: The Upper Floor room 4 (realized) + exits: + - The Upper Floor room 3 (realized) +- id: back-to-room-three + type: "/move The Upper Floor room 3" + expect: + location: The Upper Floor room 3 (realized) +- id: back-to-the-way-down + type: "/move The Upper Floor room 2" + expect: + location: The Upper Floor room 2 (realized) +- id: the-stair-down-kept-its-lock + type: "/move The Lower Floor" + why: the stair was moved onto room 2 and kept its barrier + expect: + location: The Upper Floor room 2 (realized) + refused: true + offers: locked + changed: false +- id: the-stair-key-opens-it + type: "/unlock The Lower Floor with stair key" + why: and kept its key + expect: + location: The Upper Floor room 2 (realized) + changed: true + refused: false +- id: down-to-the-lower-floor + type: "/move The Lower Floor" + why: the lower floor is laid out to its own two rooms, and the stair up lands on its entry + browser: + token: into-the-lower-floor + realizes: The Lower Floor + replies: + - purpose: location + content: + description: A colder level, the air still and the dark close. + lore: Few delvers come this far. + place_kind: house + - purpose: location + content: + description: The foot of the stair, cold and unlit. + lore: The stair comes down here from the floor above. + items: [] + people: [] + - purpose: arrival + unavailable: true + expect: + location: The Lower Floor room 1 (realized) + storey: 0 + exits: + - The Lower Floor room 2 (stub) + - The Upper Floor room 2 (realized) + refused: false +- id: the-lower-floor-has-two-rooms + type: "/move The Lower Floor room 2" + why: it has no way down, and no third room + browser: + token: lower-room-2 + realizes: The Lower Floor room 2 + replies: + - purpose: location + content: + description: A low chamber at the bottom of everything. + lore: Nothing below this has been found. + items: [] + people: [] + - purpose: arrival + unavailable: true + expect: + location: The Lower Floor room 2 (realized) + exits: + - The Lower Floor room 1 (realized) +- id: a-second-game-finds-the-floor-as-it-was-laid-out + player: second + type: "/move The Upper Floor room 1" + why: the floor was laid out once, by the first game, and the second walks the same rooms with no call + expect: + location: The Upper Floor room 1 (realized) + storey: 0 + exits: + - The Landing (realized) + - The Upper Floor room 2 (realized) +- id: and-the-same-way-down + player: second + type: "/move The Upper Floor room 2" + why: the stair now leads onto the lower floor's entry, which the first game laid out + expect: + location: The Upper Floor room 2 (realized) + exits: + - The Lower Floor room 1 (realized) + - The Upper Floor room 1 (realized) + - The Upper Floor room 3 (realized) +- id: still-locked-for-the-second-game + player: second + type: "/move The Lower Floor room 1" + why: the first game's opening is its own + expect: + location: The Upper Floor room 2 (realized) + refused: true + offers: locked diff --git a/lib/engine_sweep/scripts/a-keyed-stair-onto-a-place-stays-keyed.yml b/lib/engine_sweep/scripts/a-keyed-stair-onto-a-place-stays-keyed.yml new file mode 100644 index 00000000..0a178f82 --- /dev/null +++ b/lib/engine_sweep/scripts/a-keyed-stair-onto-a-place-stays-keyed.yml @@ -0,0 +1,83 @@ +# A LOCKED STAIR ONTO A PLACE KEEPS ITS LOCK, ITS KEY, ITS ONE-WAY DROP AND THE +# OPENING A GAME MADE THROUGH IT WHEN THE PLACE IS LAID OUT. +# +# The Old Cellar is an ordinary place (a footprint and no rooms; no `floor:`), +# and the stair onto it from the landing is keyed with the cellar key and drops +# you on the way down only (`hazard_from: The Landing`). Laying the cellar out +# moves the stair off the place onto its entry room (`#open_the_way_in!`). +# Before this, the moved stair kept only its distance and travel method: the +# lock, the key and the drop were lost, and so was the opening this game had +# already made. Now the moved stair carries all of them, each on its own side. +# +# A second game finds the stair still locked: the opening was the first +# game's. +story: Two Floors and a Key +steps: +- id: take-the-cellar-key + type: "/take cellar key" + expect: + location: The Landing (realized) + carrying: [cellar key] +- id: the-cellar-stair-is-locked + type: "/move The Old Cellar" + expect: + location: The Landing (realized) + refused: true + offers: locked + changed: false +- id: the-cellar-key-opens-it + type: "/unlock The Old Cellar with cellar key" + why: the opening is made on the stair as it is now, onto the place itself + expect: + location: The Landing (realized) + changed: true + refused: false +- id: down-into-the-cellar + type: "/move The Old Cellar" + why: the cellar is laid out and the stair moved onto its entry, keeping this game's opening, and the drop is paid on the way down + browser: + token: into-the-cellar + realizes: The Old Cellar + replies: + - purpose: location + content: + description: A low vaulted cellar, damp and close. + lore: The cellar was dug before the landing was built. + place_kind: house + - purpose: location + content: + description: The foot of the narrow stair, its last step gone. + lore: The bottom step broke long ago. + items: [] + people: [] + - purpose: arrival + unavailable: true + expect: + location: The Old Cellar room 1 (realized) + exits_include: + - The Landing (realized) + hazards: 1 + refused: false +- id: climbing-back-up-is-free + type: "/move The Landing" + why: the drop is on the landing's side only, and the opening holds both ways + expect: + location: The Landing (realized) + hazards: 0 + refused: false +- id: and-down-again-drops-again + type: "/move The Old Cellar room 1" + why: the opening still holds on the moved stair, and so does the drop + expect: + location: The Old Cellar room 1 (realized) + hazards: 1 + refused: false +- id: a-second-game-finds-it-locked + player: second + type: "/move The Old Cellar room 1" + why: the moved stair kept its lock for everybody who has not opened it + expect: + location: The Landing (realized) + refused: true + offers: locked + changed: false diff --git a/lib/engine_sweep/walk.rb b/lib/engine_sweep/walk.rb index 481ca070..a34353f3 100644 --- a/lib/engine_sweep/walk.rb +++ b/lib/engine_sweep/walk.rb @@ -283,10 +283,21 @@ def record_born_rooms!(story, before) end doors = LocationConnection.where(location: story.locations).where.not(id: before.fetch(:connections)) .includes(:location, :connected_location) - .map { |row| [ row.location.name, row.connected_location.name ].sort }.uniq + .group_by { |row| [ row.location.name, row.connected_location.name ].sort } @loaded["locations"] = Array(@loaded["locations"]) + born @loaded["connections"] = Array(@loaded["connections"]).reject { |row| moved_in(story, before).include?(Array(row["between"]).sort) } + - doors.map { |pair| { "between" => pair } } + doors.map { |pair, rows| born_door(pair, rows) } + end + + # A BORN DOOR AS THE FILE WOULD WRITE IT, with the hazard on the side that + # has one: a doorway a layout moved keeps its hazard + # (`Location::Generator#open_the_way_in!`), and every later turn must too. + def born_door(pair, rows) + hazardous = rows.find { |row| row.hazard.present? } + return { "between" => pair } if hazardous.nil? + + { "between" => pair, "hazard" => hazardous.hazard, "hazard_die" => hazardous.hazard_die, + "hazard_from" => hazardous.location.name } end # AND THE DOORS A BUILDING'S REALIZATION MOVED. Laying a building out moves diff --git a/lib/engine_sweep/worlds/two-floors-and-a-key.yml b/lib/engine_sweep/worlds/two-floors-and-a-key.yml new file mode 100644 index 00000000..dec1f116 --- /dev/null +++ b/lib/engine_sweep/worlds/two-floors-and-a-key.yml @@ -0,0 +1,112 @@ +# Two floors under a landing, and an old cellar beside it. The Upper Floor and +# The Lower Floor are FLOORS: neither has a room in this file, and each is laid +# out by the engine the first time anybody walks into it, as exactly the +# number of rooms its `floor:` names (`floors.rooms`), on one storey, its +# rooms dealt their sorts from its own `kind:` and their danger and hazard +# from its own words. The Upper Floor's way down leads to The Lower Floor, and +# the stair between them is locked with the stair key: the layout opens that +# stair onto the room it rolls for the way down, never the entry, and the +# stair keeps its lock. +# +# The Old Cellar is an ordinary place with a footprint and no rooms yet, and +# the stair onto it from the landing is locked with the cellar key and drops +# you on the way down only (`hazard_from: The Landing`). It is the doorway +# carry-over on its own: when the cellar is laid out, the stair moves onto its +# entry room and keeps its lock, its key, its one-way drop, and the opening a +# game already made through it. +format: 2 +universe: + physics: Ordinary physics. + technology: Lanterns, iron keys and hand tools. + weapons: Short blades and cudgels. + geographies: A dungeon cut down into black stone under a lantern-lit landing. + civilizations: Delvers who sell what they bring up. + history: The dungeon was cut long ago by people nobody remembers. + economics: Coin for what comes up from below. + politics: Nobody governs the dungeon. + religion: Delvers leave a coin on the landing for luck. + races: + - name: Delvers + description: People who go down into the dungeon for a living. +story: + title: Two Floors and a Key + genre: dungeon crawl + start_time: '2026-10-30T08:00:00Z' + preface: The stair down from the landing goes further than anyone has mapped. + summary: Odile goes down through two floors and a locked stair. +opening_scene: + location: The Landing + characters: [Odile] + description: You stand on the lantern-lit landing with two keys at your feet. + summary: Odile reaches the landing. +characters: +- fullname: Odile + nickname: Odile + race: Delvers + age: 29 + sex: female + is_protagonist: true + is_companion: false + backstory: A delver who has never been below the first floor. + personality: Careful and stubborn. + appearance: A lantern on a pole and a coil of rope. + likes: A clear way back. + dislikes: Doors she cannot open. + fears: The dark below the first floor. + stats: {level: 3, hit_die: 8, strength: 12, dexterity: 12, will: 12} +locations: +- name: The Landing + detail_level: realized + population: nobody + opening: true + description: A square landing of black stone lit by one lantern, a stair going down from it and a narrow stair beside it. + teaser: A lantern-lit landing. + lore: Delvers gather here before they go down. + items: + - name: stair key + description: A long iron key, cold to hold. + properties: '{}' + use_kind: key + - name: cellar key + description: A short brass key with a worn bow. + properties: '{}' + use_kind: key +- name: The Upper Floor + detail_level: stub + kind: warren + teaser: The first level of the dungeon, cut from black stone, with a stair somewhere that goes further down. + floor: + rooms: 4 + danger: uneasy + hazard: none + down: The Lower Floor +- name: The Lower Floor + detail_level: stub + kind: warren + teaser: The second level of the dungeon, colder and quieter than the first. + floor: + rooms: 2 + danger: dangerous + hazard: unlit +- name: The Old Cellar + detail_level: stub + width: 12 + depth: 9 + teaser: An old cellar beside the landing, down a narrow stair. +connections: +- between: [The Landing, The Upper Floor] + distance: adjacent + travel_method: taking stairs +- between: [The Upper Floor, The Lower Floor] + distance: adjacent + travel_method: taking stairs + barrier: keyed + key_template: stair key +- between: [The Landing, The Old Cellar] + distance: adjacent + travel_method: taking stairs + barrier: keyed + key_template: cellar key + hazard: drop + hazard_die: 4 + hazard_from: The Landing diff --git a/lib/world_seed/exporter.rb b/lib/world_seed/exporter.rb index 3fc17625..a3fa05e8 100644 --- a/lib/world_seed/exporter.rb +++ b/lib/world_seed/exporter.rb @@ -174,6 +174,7 @@ def warn_about_unexported! end report_unloadable_geometry + report_floor_rooms # A ROW THAT SAYS BOTH THINGS. `deliberately_absent` with a whereabouts is # a contradiction no code path in the app writes -- `Character#move_to!` @@ -195,6 +196,19 @@ def warn_about_unexported! report_partial_stats end + # WHAT A LAID-OUT FLOOR HOLDS THAT THE FILE CANNOT: its rooms, and the things + # and people in them. The file carries the floor's parameters, and a fresh + # load lays it out anew from them (`Floor`), so what was born in these rooms + # is left behind and said so. + def report_floor_rooms + return if floor_rooms.empty? + + things = Item.templates.where(location_id: floor_rooms.keys).count + people = story.characters.where(location_id: floor_rooms.keys).count + @warnings << "#{floor_rooms.size} room(s) of a floor not exported, with the #{things} thing(s) and #{people} " \ + "person(s) in them: the engine lays a floor out from its `floor:` key, and lays a fresh load's out anew." + end + # THE REST OF WHAT `WorldSeed::Loader#validate_boxes!` REFUSES, and it is here # for the partial box's reason one block up: `#warnings` is everything a human # has to fix before the file will load, and every one of these can stand in the @@ -415,7 +429,10 @@ def characters_document # follow on a location. The key is what makes a seeded cast reachable: # `Character.present_in` is the closed set `talk` resolves against, so a # world exported without it loads with nobody standing anywhere. - document["location"] = character.location.name if character.location + # + # SOMEBODY STANDING IN A ROOM OF A FLOOR IS WRITTEN NOWHERE, because the + # room is not written (`#locations`), and `#warn_about_unexported!` says so. + document["location"] = character.location.name if character.location && !floor_rooms.key?(character.location_id) # NOWHERE ON PURPOSE, and written only when the record says so -- the # same "omitted rather than written false" rule `opening`, `mobile` and # `readable` follow. It is mutually exclusive with `location` above @@ -568,7 +585,14 @@ def locations_document # A row in neither shape is a partial box, which the loader refuses; it is # reported by `#warnings` rather than quietly repaired here. document["parent"] = location.parent_location.name if location.parent_location - unless !@rolled_layouts && location.laid_out_alone? + # A FLOOR IS WRITTEN AS ITS PARAMETERS AND NOTHING THE ENGINE DREW: its + # footprint is the loader's to derive from `rooms` and its rooms are the + # engine's to lay out (`#locations` leaves them out), so neither is + # written and the loader refuses a floor that carries either. + if location.floor + document["floor"] = { "rooms" => location.floor.rooms, "danger" => location.floor.danger, + "hazard" => location.floor.hazard, "down" => location.floor.down_location&.name }.compact + elsif @rolled_layouts || !location.laid_out_alone? Location::Box::COLUMNS.each do |column| document[column] = location[column] unless location[column].nil? end @@ -656,10 +680,16 @@ def items_document(owner) # One entry per undirected edge, ordered by where its endpoints appear in the # locations list, so the file reads outward from the opening location. + # + # A DOORWAY ONTO A ROOM OF A FLOOR IS WRITTEN AS ONE ONTO THE FLOOR, which is + # what the file said before the engine moved it there (`#open_the_way_in!`), + # and a doorway between two rooms of one floor is the engine's and is not + # written at all. def connections_document order = locations.each_with_index.to_h { |location, index| [ location.id, index ] } - rows = LocationConnection.where(location: locations).includes(:location, :connected_location) - by_pair = rows.group_by { |row| [ row.location_id, row.connected_location_id ].sort } + rows = LocationConnection.where(location: story.locations).includes(:location, :connected_location) + by_pair = rows.group_by { |row| [ exported_id(row.location_id), exported_id(row.connected_location_id) ].sort } + by_pair.reject! { |(one, other), _| one == other } by_pair.keys.sort_by { |pair| pair.map { |id| order.fetch(id, Float::INFINITY) } }.map do |pair| edge_document(pair, by_pair.fetch(pair), order) @@ -775,7 +805,7 @@ def quest_step_document(step) end def edge_document(pair, rows, order) - row = rows.min_by { |candidate| order.fetch(candidate.location_id, Float::INFINITY) } + row = rows.min_by { |candidate| order.fetch(exported_id(candidate.location_id), Float::INFINITY) } names = pair.sort_by { |id| order.fetch(id, Float::INFINITY) }.map { |id| location_names.fetch(id) } warn_about(row, rows, names) @@ -801,12 +831,13 @@ def hazard_document(rows) return {} if hazardous.empty? row = hazardous.first + from = location_names.fetch(exported_id(row.location_id)) if hazardous.size > 1 - @warnings << "#{rows.map { |candidate| candidate.location.name }.join(" <-> ")}: both directions carry a " \ - "hazard and a file can only say one; exported #{row.location.name}'s. Fix it by hand before loading." + @warnings << "#{rows.map { |candidate| location_names.fetch(exported_id(candidate.location_id)) }.join(" <-> ")}: " \ + "both directions carry a hazard and a file can only say one; exported #{from}'s. Fix it by hand before loading." end - { "hazard" => row.hazard, "hazard_die" => row.hazard_die, "hazard_from" => row.location.name } + { "hazard" => row.hazard, "hazard_die" => row.hazard_die, "hazard_from" => from } end def warn_about(row, rows, names) @@ -830,14 +861,27 @@ def warn_about(row, rows, names) # Opening location first: Story#opening_location is the story's lowest-id # location, so the order locations appear in the file is the order the loader # creates them in and has to put the opening first. + # + # A ROOM OF A FLOOR IS NOT WRITTEN: the engine laid it out and lays a fresh + # load's out again (`Floor`), and the loader refuses a location inside one. def locations @locations ||= begin - all = story.locations.order(:id).to_a + all = story.locations.includes(:floor).order(:id).to_a.reject { |location| floor_rooms.key?(location.id) } opening = story.opening_location ([ opening ] + all.reject { |location| location == opening }).compact end end + # `{ room id => its floor's id }` for every room of a floor in the story. + def floor_rooms + @floor_rooms ||= story.locations.where(parent_location_id: Floor.where(location: story.locations).select(:location_id)) + .pluck(:id, :parent_location_id).to_h + end + + # The location a row names as the file writes it: a room of a floor is its + # floor. + def exported_id(id) = floor_rooms.fetch(id, id) + def location_names @location_names ||= locations.index_by(&:id).transform_values(&:name) end diff --git a/lib/world_seed/loader.rb b/lib/world_seed/loader.rb index ea3f2021..ed1f9d38 100644 --- a/lib/world_seed/loader.rb +++ b/lib/world_seed/loader.rb @@ -220,6 +220,7 @@ def load! story = load_story!(universe) locations = load_locations!(story) load_containment!(locations) + load_floors!(locations) load_characters!(story, universe) # AFTER THE ROOMS, THEIR THINGS AND THEIR PEOPLE, because laying a room # out gives each of them a spot. @@ -364,7 +365,7 @@ def load_locations!(story) # later entry could replay old generated detail over the author's world. # Rooms this document does not declare retain their own progress. location.assign_attributes( - attributes.except("opening", "items", "parent") + attributes.except("opening", "items", "parent", "floor") .merge("name" => written, "danger" => attributes["danger"].presence || Location::SAFE, "generation_checkpoint" => nil, "population" => attributes["population"].presence, @@ -372,7 +373,17 @@ def load_locations!(story) "hazard" => attributes["hazard"].presence, "hazard_die" => attributes["hazard_die"], "surface" => attributes["surface"].presence) .merge(Location::Box::COLUMNS.to_h { |column| [ column, attributes[column] ] }) + .merge(floor_footprint(location, attributes["floor"])) ) + # A FLOOR IS BUILT BEFORE ITS PLACE IS SAVED, so the place may carry a + # building's word (`Location#a_building_word_is_a_floors`); every key of + # it is written in `#load_floors!`, once every place exists for its way + # down to name. A place the file no longer marks a floor stops being one. + if attributes.key?("floor") + location.build_floor(rooms: attributes.dig("floor", "rooms")) if location.floor.nil? + elsif location.floor + location.floor = nil + end location.save! load_items!(story, attributes["items"], character: nil, location: location) @@ -380,6 +391,35 @@ def load_locations!(story) end end + # A FLOOR'S FOOTPRINT, derived from its count (`Floor.footprint`) so it is a + # place (`Location#place?`) before anybody walks into it -- and only while it + # has no rooms. A laid-out floor keeps the footprint its rooms were laid out + # in, whatever the file now says (VISION principle 7). Nothing for a + # location that is not a floor, whose box is the file's own. + def floor_footprint(location, floor) + return {} if floor.nil? + return { "width" => location.width, "depth" => location.depth } if location.persisted? && location.child_locations.exists? + + width, depth = Floor.footprint(Floor.room_count(floor["rooms"], down: floor["down"].present?)) + { "width" => width, "depth" => depth } + end + + # EVERY FLOOR'S ROW, every key in both directions on every load: an absent + # `danger`, `hazard` or `down` writes nil. AFTER every place exists, because + # a way down names a floor the file may declare further down. A re-seed that + # changes `rooms` after the floor is laid out changes this row only; the + # rooms are never laid out again (`Floor`). + def load_floors!(locations) + by_key = locations.transform_keys { |name| WorldSeed.natural_key(name) } + + location_documents.each do |attributes| + floor = attributes["floor"] or next + down = floor["down"].presence&.then { |name| by_key.fetch(WorldSeed.natural_key(name)) } + locations.fetch(attributes.fetch("name")).floor.update!(rooms: floor["rooms"], danger: floor["danger"], + hazard: floor["hazard"], down_location: down) + end + end + # A ROOM THE FILE LEAVES STANDING ON ITS OWN IS LAID OUT BY THE RULE A # GENERATED ONE IS, so a fresh seed and a database brought up to date # (`Update::Steps::LayOutRooms`) agree: a box at the origin of its own plane @@ -1099,6 +1139,7 @@ def validate! validate_dangers! validate_populations! validate_kinds! + validate_floors! validate_hazards! validate_boxes! validate_positions! @@ -1506,6 +1547,106 @@ def validate_limits! end end + # A FLOOR THE ENGINE COULD NOT LAY OUT AS THE FILE SAYS. `floor:` names the + # four parameters this stage reads (`Floor`), and every one of them is + # checked here with the file and the place, `#validate_limits!`'s rule: a + # count outside `1..Floor::MOST_ROOMS`, a word outside + # `Location::Parameters`' tables, or a way down that is not another floor + # this one is joined to. A key this stage does not read is refused rather + # than dropped; the later ones arrive with their stages. + # + # AND A FLOOR IS DRAWN BY THE ENGINE, NEVER BY THE FILE: its footprint is + # derived from its count (`Floor.footprint`), its rooms are laid out the + # first time anybody walks in, and it is a stub until then. So a floor + # carrying a box column or a description, or a location the file puts + # inside one, is a floor plan the engine would never read, and is refused. + FLOOR_KEYS = %w[rooms danger hazard down].freeze + + def validate_floors! + floors = location_documents.select { |attributes| attributes.key?("floor") } + by_key = floors.index_by { |attributes| WorldSeed.natural_key(attributes.fetch("name")) } + + floors.each do |attributes| + floor = attributes["floor"] + name = attributes.fetch("name").inspect + raise InvalidWorld, "#{where}: location #{name}'s `floor:` must be a map of #{FLOOR_KEYS.join(", ")}, got #{floor.inspect}" unless floor.is_a?(Hash) + + unknown = floor.keys - FLOOR_KEYS + raise InvalidWorld, "#{where}: location #{name}'s `floor:` has #{unknown.join(", ")}; there is: #{FLOOR_KEYS.join(", ")}" if unknown.any? + + rooms = floor["rooms"] + unless rooms.is_a?(Integer) && rooms.between?(1, Floor::MOST_ROOMS) + raise InvalidWorld, "#{where}: location #{name} has `floor: rooms: #{rooms.inspect}`; a floor is a whole number " \ + "of rooms from 1 to #{Floor::MOST_ROOMS}" + end + if floor["down"].present? && rooms < 2 + raise InvalidWorld, "#{where}: location #{name} has a way down and `floor: rooms: #{rooms}`; the way down is " \ + "never the entry, so a floor with one has at least 2 rooms" + end + + { "danger" => Location::Parameters::DANGER.keys, "hazard" => Location::Parameters::HAZARDS }.each do |key, words| + word = floor[key] + next if word.nil? || words.include?(word) + + raise InvalidWorld, "#{where}: location #{name} has `floor: #{key}: #{word.inspect}`; there is: #{words.join(", ")}" + end + + drawn = (Location::Box::COLUMNS & attributes.keys).reject { |column| attributes[column].nil? } + if drawn.any? + raise InvalidWorld, "#{where}: location #{name} is a floor and carries #{drawn.join(", ")}; a floor's footprint " \ + "is derived from its rooms, and the engine lays them out" + end + unless attributes.fetch("detail_level", "stub") == "stub" + raise InvalidWorld, "#{where}: location #{name} is a floor and is `detail_level: #{attributes["detail_level"].inspect}`; " \ + "a floor is a stub until somebody walks into it and the engine lays it out" + end + + validate_way_down!(attributes, by_key) + end + + location_documents.each do |attributes| + parent = attributes["parent"] + next if parent.blank? || !by_key.key?(WorldSeed.natural_key(parent)) + + raise InvalidWorld, "#{where}: location #{attributes.fetch("name").inspect} has `parent: #{parent.inspect}`, which " \ + "is a floor; the engine lays a floor's rooms out, and a file draws none of them" + end + end + + def validate_way_down!(attributes, floors) + name = attributes.fetch("name") + down = attributes.dig("floor", "down") + return if down.nil? + + key = WorldSeed.natural_key(down.to_s) + raise InvalidWorld, "#{where}: floor #{name.inspect} goes down to itself" if key == WorldSeed.natural_key(name) + unless floors.key?(key) + raise InvalidWorld, "#{where}: floor #{name.inspect} goes down to #{down.inspect}, which is not a `floor:` location " \ + "of this file" + end + + joined = connection_documents.any? do |connection| + Array(connection["between"]).map { |end_name| WorldSeed.natural_key(end_name.to_s) }.sort == + [ WorldSeed.natural_key(name), key ].sort + end + unless joined + raise InvalidWorld, "#{where}: floor #{name.inspect} goes down to #{down.inspect}, and no connection joins the two; " \ + "the way down is a doorway between them" + end + + seen = [ WorldSeed.natural_key(name) ] + walker = key + while walker + if seen.include?(walker) + ring = (seen + [ walker ]).map { |floor| floors.fetch(floor).fetch("name") } + raise InvalidWorld, "#{where}: these floors go down into each other: #{ring.join(" -> ")}" + end + + seen << walker + walker = floors[walker]&.dig("floor", "down")&.then { |next_down| WorldSeed.natural_key(next_down.to_s) } + end + end + def validate_populations! location_documents.each do |attributes| population = attributes["population"] @@ -1521,9 +1662,14 @@ def validate_populations! # lists are closed, so a word outside them is a typo and is named here with # the file and the room, `#validate_populations!`'s rule and its reason; a # blank key means the file does not say, and passes. + # + # A FLOOR'S `kind:` IS A BUILDING'S (`Location::Kind::BUILDINGS`): nobody + # stands in a floor, and its word deals its rooms their sorts as the engine + # lays them out (`Floor`). def validate_kinds! location_documents.each do |attributes| - { "kind" => Location::Kind::KINDS, "density" => Location::Kind::DENSITIES }.each do |key, words| + kinds = attributes.key?("floor") ? Location::Kind::BUILDINGS : Location::Kind::KINDS + { "kind" => kinds, "density" => Location::Kind::DENSITIES }.each do |key, words| word = attributes[key] next if word.blank? || words.include?(word) diff --git a/test/engine_parity/a-floor-laid-out-to-its-count.checks.json b/test/engine_parity/a-floor-laid-out-to-its-count.checks.json new file mode 100644 index 00000000..053d9a19 --- /dev/null +++ b/test/engine_parity/a-floor-laid-out-to-its-count.checks.json @@ -0,0 +1,25 @@ +{ + "script": "a-floor-laid-out-to-its-count", + "doctor": { + "headline": "playable, 2 warnings", + "findings": [ + [ + "no_arc", + "warning", + "this story has no arc, so no playthrough of it can ever reach an ending: there is no quest for a beat to belong to and no outcome to select. A world file's `quests:` block is where one is written", + "manual" + ], + [ + "room_without_a_layout", + "warning", + "\"The Landing\" has no layout: nobody can be given a spot in it, so what is within reach there cannot be measured", + "manual" + ] + ] + }, + "audit": { + "headline": "1 scenes, nothing to report", + "flags": [], + "unjudged": [] + } +} diff --git a/test/engine_parity/a-floor-laid-out-to-its-count.json b/test/engine_parity/a-floor-laid-out-to-its-count.json new file mode 100644 index 00000000..92206cf9 --- /dev/null +++ b/test/engine_parity/a-floor-laid-out-to-its-count.json @@ -0,0 +1,1293 @@ +{ + "script": "a-floor-laid-out-to-its-count", + "story": "Two Floors and a Key", + "steps": [ + { + "step": "step 1 take-the-stair-key", + "player": "first", + "typed": "/take stair key", + "dump": { + "location": { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + "storey": null, + "exits": [ + { + "id": 1000000004, + "name": "The Old Cellar", + "detail": "stub" + }, + { + "id": 1000000002, + "name": "The Upper Floor", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000004, + "name": "cellar key" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "took: stair key (was lying in The Landing, now carried by Odile)", + "refused": false, + "offers": null, + "understood": "take -> stair key", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 2 into-the-upper-floor", + "player": "first", + "typed": "/move The Upper Floor", + "dump": { + "location": { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000017, + "name": "bone" + }, + { + "id": 1000000018, + "name": "candle stub" + }, + { + "id": 1000000020, + "name": "coin" + }, + { + "id": 1000000016, + "name": "ledge" + }, + { + "id": 1000000015, + "name": "pool" + }, + { + "id": 1000000013, + "name": "rock shelf" + }, + { + "id": 1000000014, + "name": "rubble" + }, + { + "id": 1000000019, + "name": "shard of pottery" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "The browser turn completed.", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor", + "resolved_by": "grammar", + "note": "into-the-upper-floor: completed", + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 1.5, + "shown": null + } + }, + { + "step": "step 3 the-way-down-is-off-room-two", + "player": "first", + "typed": "/move The Upper Floor room 2", + "dump": { + "location": { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000003, + "name": "The Lower Floor", + "detail": "stub" + }, + { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000036, + "name": "bone" + }, + { + "id": 1000000038, + "name": "coin" + }, + { + "id": 1000000034, + "name": "crack" + }, + { + "id": 1000000035, + "name": "pebble" + }, + { + "id": 1000000033, + "name": "pool" + }, + { + "id": 1000000030, + "name": "rock shelf" + }, + { + "id": 1000000031, + "name": "rubble" + }, + { + "id": 1000000037, + "name": "rusted hinge" + }, + { + "id": 1000000032, + "name": "stalagmite" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "The browser turn completed.", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor room 2", + "resolved_by": "grammar", + "note": "room-2: completed", + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 1.0, + "shown": null + } + }, + { + "step": "step 4 room-three", + "player": "first", + "typed": "/move The Upper Floor room 3", + "dump": { + "location": { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + { + "id": 1000000008, + "name": "The Upper Floor room 4", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000050, + "name": "bone" + }, + { + "id": 1000000049, + "name": "candle stub" + }, + { + "id": 1000000048, + "name": "crack" + }, + { + "id": 1000000047, + "name": "pool" + }, + { + "id": 1000000045, + "name": "rock shelf" + }, + { + "id": 1000000046, + "name": "rubble" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "The browser turn completed.", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor room 3", + "resolved_by": "grammar", + "note": "room-3: completed", + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 1.0, + "shown": null + } + }, + { + "step": "step 5 room-four-is-the-last", + "player": "first", + "typed": "/move The Upper Floor room 4", + "dump": { + "location": { + "id": 1000000008, + "name": "The Upper Floor room 4", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000061, + "name": "bone" + }, + { + "id": 1000000060, + "name": "ledge" + }, + { + "id": 1000000057, + "name": "rock shelf" + }, + { + "id": 1000000058, + "name": "rubble" + }, + { + "id": 1000000059, + "name": "stalagmite" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "The browser turn completed.", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor room 4", + "resolved_by": "grammar", + "note": "room-4: completed", + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 1.0, + "shown": null + } + }, + { + "step": "step 6 back-to-room-three", + "player": "first", + "typed": "/move The Upper Floor room 3", + "dump": { + "location": { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + { + "id": 1000000008, + "name": "The Upper Floor room 4", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000050, + "name": "bone" + }, + { + "id": 1000000049, + "name": "candle stub" + }, + { + "id": 1000000048, + "name": "crack" + }, + { + "id": 1000000047, + "name": "pool" + }, + { + "id": 1000000045, + "name": "rock shelf" + }, + { + "id": 1000000046, + "name": "rubble" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "moved: The Upper Floor room 4 -> The Upper Floor room 3", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor room 3", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 7 back-to-the-way-down", + "player": "first", + "typed": "/move The Upper Floor room 2", + "dump": { + "location": { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000003, + "name": "The Lower Floor", + "detail": "stub" + }, + { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000036, + "name": "bone" + }, + { + "id": 1000000038, + "name": "coin" + }, + { + "id": 1000000034, + "name": "crack" + }, + { + "id": 1000000035, + "name": "pebble" + }, + { + "id": 1000000033, + "name": "pool" + }, + { + "id": 1000000030, + "name": "rock shelf" + }, + { + "id": 1000000031, + "name": "rubble" + }, + { + "id": 1000000037, + "name": "rusted hinge" + }, + { + "id": 1000000032, + "name": "stalagmite" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "moved: The Upper Floor room 3 -> The Upper Floor room 2", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor room 2", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 8 the-stair-down-kept-its-lock", + "player": "first", + "typed": "/move The Lower Floor", + "dump": { + "location": { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000003, + "name": "The Lower Floor", + "detail": "stub" + }, + { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000036, + "name": "bone" + }, + { + "id": 1000000038, + "name": "coin" + }, + { + "id": 1000000034, + "name": "crack" + }, + { + "id": 1000000035, + "name": "pebble" + }, + { + "id": 1000000033, + "name": "pool" + }, + { + "id": 1000000030, + "name": "rock shelf" + }, + { + "id": 1000000031, + "name": "rubble" + }, + { + "id": 1000000037, + "name": "rusted hinge" + }, + { + "id": 1000000032, + "name": "stalagmite" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": false, + "change": null, + "refused": true, + "offers": "The way to The Lower Floor is locked. Open it before crossing or throwing anything through it. Nothing has changed.", + "understood": "move -> The Lower Floor", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 9 the-stair-key-opens-it", + "player": "first", + "typed": "/unlock The Lower Floor with stair key", + "dump": { + "location": { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000003, + "name": "The Lower Floor", + "detail": "stub" + }, + { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000036, + "name": "bone" + }, + { + "id": 1000000038, + "name": "coin" + }, + { + "id": 1000000034, + "name": "crack" + }, + { + "id": 1000000035, + "name": "pebble" + }, + { + "id": 1000000033, + "name": "pool" + }, + { + "id": 1000000030, + "name": "rock shelf" + }, + { + "id": 1000000031, + "name": "rubble" + }, + { + "id": 1000000037, + "name": "rusted hinge" + }, + { + "id": 1000000032, + "name": "stalagmite" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "You opened the way to The Lower Floor. You have not crossed it; you remain in The Upper Floor room 2.", + "refused": false, + "offers": null, + "understood": "use -> The Lower Floor", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 10 down-to-the-lower-floor", + "player": "first", + "typed": "/move The Lower Floor", + "dump": { + "location": { + "id": 1000000009, + "name": "The Lower Floor room 1", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000010, + "name": "The Lower Floor room 2", + "detail": "stub" + }, + { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000073, + "name": "bone" + }, + { + "id": 1000000072, + "name": "candle stub" + }, + { + "id": 1000000071, + "name": "pool" + }, + { + "id": 1000000069, + "name": "rock shelf" + }, + { + "id": 1000000070, + "name": "rubble" + }, + { + "id": 1000000074, + "name": "shard of pottery" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "The browser turn completed.", + "refused": false, + "offers": null, + "understood": "move -> The Lower Floor", + "resolved_by": "grammar", + "note": "into-the-lower-floor: completed", + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 1.5, + "shown": null + } + }, + { + "step": "step 11 the-lower-floor-has-two-rooms", + "player": "first", + "typed": "/move The Lower Floor room 2", + "dump": { + "location": { + "id": 1000000010, + "name": "The Lower Floor room 2", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000009, + "name": "The Lower Floor room 1", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000084, + "name": "bat skull" + }, + { + "id": 1000000085, + "name": "bone" + }, + { + "id": 1000000086, + "name": "coin" + }, + { + "id": 1000000083, + "name": "crack" + }, + { + "id": 1000000081, + "name": "rock shelf" + }, + { + "id": 1000000082, + "name": "rubble" + } + ], + "carrying": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "The browser turn completed.", + "refused": false, + "offers": null, + "understood": "move -> The Lower Floor room 2", + "resolved_by": "grammar", + "note": "lower-room-2: completed", + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 1.0, + "shown": null + } + }, + { + "step": "step 12 a-second-game-finds-the-floor-as-it-was-laid-out (second)", + "player": "second", + "typed": "/move The Upper Floor room 1", + "dump": { + "location": { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000093, + "name": "bone" + }, + { + "id": 1000000094, + "name": "candle stub" + }, + { + "id": 1000000096, + "name": "coin" + }, + { + "id": 1000000092, + "name": "ledge" + }, + { + "id": 1000000091, + "name": "pool" + }, + { + "id": 1000000089, + "name": "rock shelf" + }, + { + "id": 1000000090, + "name": "rubble" + }, + { + "id": 1000000095, + "name": "shard of pottery" + } + ], + "carrying": [], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "moved: The Landing -> The Upper Floor room 1", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor room 1", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 13 and-the-same-way-down (second)", + "player": "second", + "typed": "/move The Upper Floor room 2", + "dump": { + "location": { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000009, + "name": "The Lower Floor room 1", + "detail": "realized" + }, + { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000103, + "name": "bone" + }, + { + "id": 1000000105, + "name": "coin" + }, + { + "id": 1000000101, + "name": "crack" + }, + { + "id": 1000000102, + "name": "pebble" + }, + { + "id": 1000000100, + "name": "pool" + }, + { + "id": 1000000097, + "name": "rock shelf" + }, + { + "id": 1000000098, + "name": "rubble" + }, + { + "id": 1000000104, + "name": "rusted hinge" + }, + { + "id": 1000000099, + "name": "stalagmite" + } + ], + "carrying": [], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "moved: The Upper Floor room 1 -> The Upper Floor room 2", + "refused": false, + "offers": null, + "understood": "move -> The Upper Floor room 2", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 14 still-locked-for-the-second-game (second)", + "player": "second", + "typed": "/move The Lower Floor room 1", + "dump": { + "location": { + "id": 1000000006, + "name": "The Upper Floor room 2", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000009, + "name": "The Lower Floor room 1", + "detail": "realized" + }, + { + "id": 1000000005, + "name": "The Upper Floor room 1", + "detail": "realized" + }, + { + "id": 1000000007, + "name": "The Upper Floor room 3", + "detail": "realized" + } + ], + "here": [ + { + "id": 1000000103, + "name": "bone" + }, + { + "id": 1000000105, + "name": "coin" + }, + { + "id": 1000000101, + "name": "crack" + }, + { + "id": 1000000102, + "name": "pebble" + }, + { + "id": 1000000100, + "name": "pool" + }, + { + "id": 1000000097, + "name": "rock shelf" + }, + { + "id": 1000000098, + "name": "rubble" + }, + { + "id": 1000000104, + "name": "rusted hinge" + }, + { + "id": 1000000099, + "name": "stalagmite" + } + ], + "carrying": [], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": false, + "change": null, + "refused": true, + "offers": "The way to The Lower Floor room 1 is locked. Open it before crossing or throwing anything through it. Nothing has changed.", + "understood": "move -> The Lower Floor room 1", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + } + ] +} diff --git a/test/engine_parity/a-keyed-stair-onto-a-place-stays-keyed.checks.json b/test/engine_parity/a-keyed-stair-onto-a-place-stays-keyed.checks.json new file mode 100644 index 00000000..af934307 --- /dev/null +++ b/test/engine_parity/a-keyed-stair-onto-a-place-stays-keyed.checks.json @@ -0,0 +1,25 @@ +{ + "script": "a-keyed-stair-onto-a-place-stays-keyed", + "doctor": { + "headline": "playable, 2 warnings", + "findings": [ + [ + "no_arc", + "warning", + "this story has no arc, so no playthrough of it can ever reach an ending: there is no quest for a beat to belong to and no outcome to select. A world file's `quests:` block is where one is written", + "manual" + ], + [ + "room_without_a_layout", + "warning", + "\"The Landing\" has no layout: nobody can be given a spot in it, so what is within reach there cannot be measured", + "manual" + ] + ] + }, + "audit": { + "headline": "1 scenes, nothing to report", + "flags": [], + "unjudged": [] + } +} diff --git a/test/engine_parity/a-keyed-stair-onto-a-place-stays-keyed.json b/test/engine_parity/a-keyed-stair-onto-a-place-stays-keyed.json new file mode 100644 index 00000000..654461f0 --- /dev/null +++ b/test/engine_parity/a-keyed-stair-onto-a-place-stays-keyed.json @@ -0,0 +1,540 @@ +{ + "script": "a-keyed-stair-onto-a-place-stays-keyed", + "story": "Two Floors and a Key", + "steps": [ + { + "step": "step 1 take-the-cellar-key", + "player": "first", + "typed": "/take cellar key", + "dump": { + "location": { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + "storey": null, + "exits": [ + { + "id": 1000000004, + "name": "The Old Cellar", + "detail": "stub" + }, + { + "id": 1000000002, + "name": "The Upper Floor", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "carrying": [ + { + "id": 1000000004, + "name": "cellar key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "took: cellar key (was lying in The Landing, now carried by Odile)", + "refused": false, + "offers": null, + "understood": "take -> cellar key", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 2 the-cellar-stair-is-locked", + "player": "first", + "typed": "/move The Old Cellar", + "dump": { + "location": { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + "storey": null, + "exits": [ + { + "id": 1000000004, + "name": "The Old Cellar", + "detail": "stub" + }, + { + "id": 1000000002, + "name": "The Upper Floor", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "carrying": [ + { + "id": 1000000004, + "name": "cellar key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": false, + "change": null, + "refused": true, + "offers": "The way to The Old Cellar is locked. Open it before crossing or throwing anything through it. Nothing has changed.", + "understood": "move -> The Old Cellar", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 3 the-cellar-key-opens-it", + "player": "first", + "typed": "/unlock The Old Cellar with cellar key", + "dump": { + "location": { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + "storey": null, + "exits": [ + { + "id": 1000000004, + "name": "The Old Cellar", + "detail": "stub" + }, + { + "id": 1000000002, + "name": "The Upper Floor", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "carrying": [ + { + "id": 1000000004, + "name": "cellar key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "You opened the way to The Old Cellar. You have not crossed it; you remain in The Landing.", + "refused": false, + "offers": null, + "understood": "use -> The Old Cellar", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 4 down-into-the-cellar", + "player": "first", + "typed": "/move The Old Cellar", + "dump": { + "location": { + "id": 1000000005, + "name": "The Old Cellar room 1", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + { + "id": 1000000006, + "name": "The Old Cellar room 2", + "detail": "stub" + }, + { + "id": 1000000008, + "name": "The Old Cellar room 4", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000017, + "name": "bowl" + }, + { + "id": 1000000016, + "name": "cupboard" + }, + { + "id": 1000000019, + "name": "dead fly" + }, + { + "id": 1000000020, + "name": "feather" + }, + { + "id": 1000000014, + "name": "shelves" + }, + { + "id": 1000000018, + "name": "spool of thread" + }, + { + "id": 1000000013, + "name": "table" + }, + { + "id": 1000000015, + "name": "windowsill" + } + ], + "carrying": [ + { + "id": 1000000004, + "name": "cellar key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "The browser turn completed.", + "refused": false, + "offers": null, + "understood": "move -> The Old Cellar", + "resolved_by": "grammar", + "note": "into-the-cellar: completed", + "drifts": 0, + "blows": 0, + "hazards": 1, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 1.5, + "shown": null + } + }, + { + "step": "step 5 climbing-back-up-is-free", + "player": "first", + "typed": "/move The Landing", + "dump": { + "location": { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + "storey": null, + "exits": [ + { + "id": 1000000005, + "name": "The Old Cellar room 1", + "detail": "realized" + }, + { + "id": 1000000002, + "name": "The Upper Floor", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000003, + "name": "stair key" + } + ], + "carrying": [ + { + "id": 1000000004, + "name": "cellar key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "moved: The Old Cellar room 1 -> The Landing", + "refused": false, + "offers": null, + "understood": "move -> The Landing", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 6 and-down-again-drops-again", + "player": "first", + "typed": "/move The Old Cellar room 1", + "dump": { + "location": { + "id": 1000000005, + "name": "The Old Cellar room 1", + "detail": "realized" + }, + "storey": 0, + "exits": [ + { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + { + "id": 1000000006, + "name": "The Old Cellar room 2", + "detail": "stub" + }, + { + "id": 1000000008, + "name": "The Old Cellar room 4", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000017, + "name": "bowl" + }, + { + "id": 1000000016, + "name": "cupboard" + }, + { + "id": 1000000019, + "name": "dead fly" + }, + { + "id": 1000000020, + "name": "feather" + }, + { + "id": 1000000014, + "name": "shelves" + }, + { + "id": 1000000018, + "name": "spool of thread" + }, + { + "id": 1000000013, + "name": "table" + }, + { + "id": 1000000015, + "name": "windowsill" + } + ], + "carrying": [ + { + "id": 1000000004, + "name": "cellar key" + } + ], + "present": [], + "foes": [], + "inscription": [], + "hp": 17, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": true, + "change": "moved: The Landing -> The Old Cellar room 1", + "refused": false, + "offers": null, + "understood": "move -> The Old Cellar room 1", + "resolved_by": "grammar", + "note": "the world: drop on the way from The Landing into The Old Cellar room 1 cost Odile 1 hit point (the way down is further than it looks from the top); Odile is hurt (17 of 18)", + "drifts": 0, + "blows": 0, + "hazards": 1, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + }, + { + "step": "step 7 a-second-game-finds-it-locked (second)", + "player": "second", + "typed": "/move The Old Cellar room 1", + "dump": { + "location": { + "id": 1000000001, + "name": "The Landing", + "detail": "realized" + }, + "storey": null, + "exits": [ + { + "id": 1000000005, + "name": "The Old Cellar room 1", + "detail": "realized" + }, + { + "id": 1000000002, + "name": "The Upper Floor", + "detail": "stub" + } + ], + "here": [ + { + "id": 1000000022, + "name": "cellar key" + }, + { + "id": 1000000021, + "name": "stair key" + } + ], + "carrying": [], + "present": [], + "foes": [], + "inscription": [], + "hp": 18, + "hp_of": {}, + "abilities": { + "strength": 12, + "dexterity": 12, + "will": 12 + }, + "dead": false, + "changed": false, + "change": null, + "refused": true, + "offers": "The way to The Old Cellar room 1 is locked. Open it before crossing or throwing anything through it. Nothing has changed.", + "understood": "move -> The Old Cellar room 1", + "resolved_by": "grammar", + "note": null, + "drifts": 0, + "blows": 0, + "hazards": 0, + "quest": {}, + "volitions": 0, + "acts": 0, + "ending": "none", + "ending_words": null, + "scheduled": 0, + "fired": 0, + "elapsed_minutes": 0.0, + "shown": null + } + } + ] +} diff --git a/test/factories/floors.rb b/test/factories/floors.rb new file mode 100644 index 00000000..64d695ba --- /dev/null +++ b/test/factories/floors.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + # A floor of four rooms, with no way down and none of its own words: the + # place is a stub carrying the footprint the loader would derive for it + # (`Floor.footprint`), so it is a place nobody has walked into yet. Nothing + # here rolls; ask for a way down or words by passing them. + factory :floor do + location { association :location, :stub, width: 12, depth: 12 } + rooms { 4 } + end +end diff --git a/test/lib/world_seed/exporter_test.rb b/test/lib/world_seed/exporter_test.rb index e71699db..c9cc2f69 100644 --- a/test/lib/world_seed/exporter_test.rb +++ b/test/lib/world_seed/exporter_test.rb @@ -323,6 +323,52 @@ def setup assert_equal [ 20, 200 ], [ reloaded.people_limit, reloaded.things_limit ] end + # A FLOOR IS WRITTEN AS ITS PARAMETERS (`Floor`): the loader derives its + # footprint and the engine lays out its rooms, so neither is written. + test "exports a floor as its parameters, and a world with floors round-trips" do + world = WorldSeed.parse(Rails.root.join("lib/engine_sweep/worlds/two-floors-and-a-key.yml").read) + story = WorldSeed::Loader.new(world).load! + + document = WorldSeed::Exporter.new(story).document + rows = document["locations"].index_by { |row| row["name"] } + assert_equal({ "rooms" => 4, "danger" => "uneasy", "hazard" => "none", "down" => "The Lower Floor" }, + rows["The Upper Floor"]["floor"]) + assert_equal({ "rooms" => 2, "danger" => "dangerous", "hazard" => "unlit" }, rows["The Lower Floor"]["floor"]) + assert_not rows["The Upper Floor"].key?("width"), "the loader derives a floor's footprint" + assert_not rows["The Old Cellar"].key?("floor") + + document["story"]["title"] = "Two Floors Again" + reloaded = WorldSeed::Loader.new(WorldSeed.parse(WorldSeed.dump(document))).load! + floors = ->(of) { Floor.where(location: of.locations).includes(:location, :down_location).order(:id).map { |floor| [ floor.location.name, floor.rooms, floor.danger, floor.hazard, floor.down_location&.name ] } } + assert_equal floors.call(story), floors.call(reloaded) + assert_equal [ 12, 12 ], reloaded.locations.find_by!(name: "The Upper Floor").slice(:width, :depth).values + end + + # AND A LAID-OUT FLOOR'S ROOMS ARE THE ENGINE'S, so they are not written: a + # doorway onto one is written onto the floor, which is where the file had it, + # and the loader lays a fresh copy out anew. + test "a laid-out floor exports without its rooms, its doorways written onto the floor" do + world = WorldSeed.parse(Rails.root.join("lib/engine_sweep/worlds/two-floors-and-a-key.yml").read) + story = WorldSeed::Loader.new(world).load! + upper = story.locations.find_by!(name: "The Upper Floor") + Location::Interior.lay_out!(upper) + Location::Generator.new(upper).open_the_way_in! + assert_empty upper.reload.exits, "the fixture moves the floor's doorways onto its rooms" + + exporter = WorldSeed::Exporter.new(story) + document = exporter.document + + assert_equal world["locations"].map { |row| row["name"] }, document["locations"].map { |row| row["name"] } + assert_equal world["connections"].map { |row| row["between"].sort }.sort, + document["connections"].map { |row| row["between"].sort }.sort + stair = document["connections"].find { |row| row["between"].sort == [ "The Lower Floor", "The Upper Floor" ] } + assert_equal [ "keyed", "stair key" ], stair.values_at("barrier", "key_template") + assert(exporter.warnings.any? { |line| line.match?(/room\(s\) of a floor not exported/) }) + + document["story"]["title"] = "Two Floors Again" + WorldSeed::Loader.new(WorldSeed.parse(WorldSeed.dump(document))).load! + end + test "exports a thing's material, and a world with one round-trips" do create(:character, :protagonist, story: @story, fullname: "Isbet Marrow") create(:item, character: nil, location: @opening, name: "A Clay Jar", material: "pottery", fragility: "brittle") diff --git a/test/lib/world_seed/loader_test.rb b/test/lib/world_seed/loader_test.rb index d9b9d628..d597dab8 100644 --- a/test/lib/world_seed/loader_test.rb +++ b/test/lib/world_seed/loader_test.rb @@ -383,6 +383,102 @@ def sheet(level: 1, hit_die: 8, strength: 12, dexterity: 10, will: 14) assert_match(/`limits:` has rooms; there is: people, things/, error.message) end + # A FLOOR (`Floor`): its row, every key in both directions, and the footprint + # the loader derives from its count so it is a place before anybody walks in. + test "seeds a floor with its row and the footprint its count derives" do + story = WorldSeed::Loader.new(floors_world).load! + upper, lower = %w[The\ Upper\ Floor The\ Lower\ Floor].map { |name| story.locations.find_by!(name: name) } + + assert_equal [ 4, "uneasy", "none", lower ], upper.floor.values_at(:rooms, :danger, :hazard).push(upper.floor.down_location) + assert_equal [ 2, "dangerous", "unlit", nil ], lower.floor.values_at(:rooms, :danger, :hazard).push(lower.floor.down_location) + assert_equal [ 12, 12 ], [ upper.width, upper.depth ] + assert_equal [ 12, 6 ], [ lower.width, lower.depth ] + assert_equal "warren", upper.kind + assert_predicate upper, :place? + assert_nil story.locations.find_by!(name: "The Old Cellar").floor + end + + test "an absent floor key writes nil, and a place the file stops calling a floor is not one" do + WorldSeed::Loader.new(floors_world).load! + + world = floors_world + upper = world["locations"].find { |row| row["name"] == "The Upper Floor" } + upper["floor"] = { "rooms" => 4 } + world["locations"].find { |row| row["name"] == "The Lower Floor" }.except!("floor", "kind") + story = WorldSeed::Loader.new(world).load! + + floor = story.locations.find_by!(name: "The Upper Floor").floor + assert_equal [ 4, nil, nil, nil ], [ floor.rooms, floor.danger, floor.hazard, floor.down_location_id ] + assert_nil story.locations.find_by!(name: "The Lower Floor").floor + assert_equal 1, Floor.where(location: story.locations).count + end + + # VISION principle 7: a floor is laid out once. A re-seed that changes its + # count after that changes the row and nothing it drew. + test "re-seeding a laid-out floor changes its row and leaves its rooms and footprint alone" do + story = WorldSeed::Loader.new(floors_world).load! + upper = story.locations.find_by!(name: "The Upper Floor") + Location::Interior.lay_out!(upper) + rooms = upper.child_locations.order(:id).pluck(:id, :x, :y, :z, :width, :depth) + + world = floors_world + world["locations"].find { |row| row["name"] == "The Upper Floor" }["floor"]["rooms"] = 9 + WorldSeed::Loader.new(world).load! + + upper.reload + assert_equal 9, upper.floor.rooms + assert_equal [ 12, 12 ], [ upper.width, upper.depth ] + assert_equal rooms, upper.child_locations.order(:id).pluck(:id, :x, :y, :z, :width, :depth) + end + + test "rejects a floor the engine could not lay out as the file says" do + { + ->(floor, _) { floor["rooms"] = "4" } => /`floor: rooms: "4"`; a floor is a whole number of rooms from 1 to 1000/, + ->(floor, _) { floor["rooms"] = 0 } => /`floor: rooms: 0`/, + ->(floor, _) { floor["rooms"] = 1001 } => /`floor: rooms: 1001`/, + ->(floor, _) { floor["rooms"] = 1 } => /has a way down and `floor: rooms: 1`/, + ->(floor, _) { floor["danger"] = "deadly" } => /`floor: danger: "deadly"`; there is: safe, uneasy, dangerous/, + ->(floor, _) { floor["hazard"] = "lava" } => /`floor: hazard: "lava"`; there is: none, flooded/, + ->(floor, _) { floor["guards"] = 2 } => /`floor:` has guards; there is: rooms, danger, hazard, down/, + ->(floor, _) { floor["down"] = "The Old Cellar" } => /goes down to "The Old Cellar", which is not a `floor:` location/, + ->(floor, _) { floor["down"] = "The Upper Floor" } => /"The Upper Floor" goes down to itself/, + ->(_, row) { row["width"] = 12 } => /is a floor and carries width; a floor's footprint is derived/, + ->(_, row) { row["z"] = 0 } => /is a floor and carries z/, + ->(_, row) { row.merge!("detail_level" => "realized", "description" => "A floor.", "lore" => "Old.") } => + /is a floor and is `detail_level: "realized"`/, + ->(_, row) { row["kind"] = "cave" } => /`kind: "cave"`; there is: house, farmhouse/ + }.each do |edit, message| + world = floors_world + row = world["locations"].find { |candidate| candidate["name"] == "The Upper Floor" } + edit.call(row["floor"], row) + + error = assert_raises(WorldSeed::Loader::InvalidWorld) { WorldSeed::Loader.new(world).load! } + assert_match message, error.message + end + assert_nil Story.find_by(title: "Two Floors and a Key") + end + + test "rejects a way down no connection joins, and floors that go down into each other" do + world = floors_world + world["connections"].reject! { |row| row["between"] == [ "The Upper Floor", "The Lower Floor" ] } + error = assert_raises(WorldSeed::Loader::InvalidWorld) { WorldSeed::Loader.new(world).load! } + assert_match(/goes down to "The Lower Floor", and no connection joins the two/, error.message) + + world = floors_world + world["locations"].find { |row| row["name"] == "The Lower Floor" }["floor"]["down"] = "The Upper Floor" + error = assert_raises(WorldSeed::Loader::InvalidWorld) { WorldSeed::Loader.new(world).load! } + assert_match(/these floors go down into each other: The Upper Floor -> The Lower Floor -> The Upper Floor/, error.message) + end + + test "rejects a location the file puts inside a floor" do + world = floors_world + world["locations"] << { "name" => "A Drawn Room", "detail_level" => "stub", "parent" => "The Upper Floor", + "teaser" => "A room somebody drew by hand." } + + error = assert_raises(WorldSeed::Loader::InvalidWorld) { WorldSeed::Loader.new(world).load! } + assert_match(/"A Drawn Room" has `parent: "The Upper Floor"`, which is a floor/, error.message) + end + # WHAT SORT OF PLACE A ROOM IS AND HOW CLUTTERED, on `population`'s terms: an # absent key is nobody picked, re-asserted in both directions, and a word the # list lacks is refused with the file and the room (`Location::Kind`). @@ -1991,6 +2087,10 @@ def with_a_placed_thing(**overrides) assert_match(/is in no room/, error.message) end + # The sweep's world of two floors and a keyed cellar stair, fresh on every + # call, for `document`'s reason. + def floors_world = WorldSeed.parse(Rails.root.join("lib/engine_sweep/worlds/two-floors-and-a-key.yml").read) + # Built fresh on every call so a test can edit it without touching another's. def document WorldSeed.parse(WorldSeed.dump( diff --git a/test/models/floor_test.rb b/test/models/floor_test.rb new file mode 100644 index 00000000..6427f84f --- /dev/null +++ b/test/models/floor_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +class FloorTest < ActiveSupport::TestCase + test "the factory builds a valid floor" do + assert_predicate build(:floor), :valid? + end + + test "a floor is a whole number of rooms from 1 to MOST_ROOMS" do + assert_predicate build(:floor, rooms: 1), :valid? + assert_predicate build(:floor, rooms: Floor::MOST_ROOMS), :valid? + [ 0, Floor::MOST_ROOMS + 1, 2.5, nil ].each do |rooms| + assert_not build(:floor, rooms: rooms).valid?, "#{rooms.inspect} rooms is not a floor" + end + end + + test "a floor with a way down has at least two rooms, since the way down is never the entry" do + below = create(:floor) + floor = build(:floor, location: create(:location, :stub, story: below.location.story), down_location: below.location, rooms: 1) + + assert_not floor.valid? + floor.rooms = 2 + assert_predicate floor, :valid? + end + + test "its danger and hazard are words of the place call's own tables" do + assert_predicate build(:floor, danger: "uneasy", hazard: "flooded"), :valid? + assert_predicate build(:floor, hazard: "none"), :valid? + assert_not build(:floor, danger: "deadly").valid?, "deadly is a seed file's room word, not a building's" + assert_not build(:floor, hazard: "lava").valid? + end + + test "its way down is another floor of the same story" do + floor = create(:floor) + story = floor.location.story + + floor.down_location = floor.location + assert_includes floor.tap(&:validate).errors[:down_location], "is this floor itself" + + floor.down_location = create(:location, :stub, story: story) + assert_includes floor.tap(&:validate).errors[:down_location], "is not a floor" + + floor.down_location = create(:floor).location + assert_includes floor.tap(&:validate).errors[:down_location], "is in another story" + + floor.down_location = create(:floor, location: create(:location, :stub, story: story)).location + assert_predicate floor, :valid? + end + + # The engine's `floor::room_count`, `floor::grid` and `floor::footprint`. + test "the count, the grid and the footprint are the engine's" do + assert_equal 1, Floor.room_count(0) + assert_equal 2, Floor.room_count(1, down: true) + assert_equal Floor::MOST_ROOMS, Floor.room_count(5000) + + assert_equal [ 1, 1 ], Floor.grid(1) + assert_equal [ 2, 1 ], Floor.grid(2) + assert_equal [ 2, 2 ], Floor.grid(4) + assert_equal [ 3, 2 ], Floor.grid(5) + assert_equal [ 3, 3 ], Floor.grid(9) + assert_equal [ 4, 3 ], Floor.grid(10) + assert_equal [ 32, 32 ], Floor.grid(1000) + + assert_equal [ 12, 12 ], Floor.footprint(4) + assert_equal [ 12, 6 ], Floor.footprint(2) + end + + test "a place may carry a building's word only when it is a floor" do + place = create(:location, :stub, width: 12, depth: 12) + place.kind = "warren" + assert_not place.valid? + + create(:floor, location: place) + assert_predicate place.reload.tap { |floor| floor.kind = "warren" }, :valid? + end + + test "a floor goes with its place" do + floor = create(:floor) + + floor.location.destroy! + + assert_not Floor.exists?(floor.id) + end +end diff --git a/test/models/location/generator_test.rb b/test/models/location/generator_test.rb index c6e76270..4f8b7c80 100644 --- a/test/models/location/generator_test.rb +++ b/test/models/location/generator_test.rb @@ -351,6 +351,87 @@ def exit_named(name, inside: nil) assert_equal "climbing", edge.travel_method end + # AND KEEPS EVERYTHING ELSE IT WAS, side by side: its lock and key on both + # rows, its hazard on the row that had one, every game's opening through it + # and every toll paid crossing it. renderedstep/engine#94 made the same move + # in the engine's `open_the_way_in`. + def keyed_cellar + place = stub_location(name: "The Old Cellar", width: 12, depth: 9) + landing = create(:location, story: @story, name: "The Landing") + key = create(:item, character: nil, location: landing, use_kind: "key", name: "cellar key") + down = create(:location_connection, location: landing, connected_location: place, distance: "adjacent", + travel_method: "taking stairs", barrier: "keyed", key_template: key, + hazard: "drop", hazard_die: 4) + up = create(:location_connection, location: place, connected_location: landing, distance: "adjacent", + travel_method: "taking stairs", barrier: "keyed", key_template: key) + [ place, landing, key, down, up ] + end + + def lay_out_and_open!(place) + Location::Interior.lay_out!(place) + Location::Generator.new(place).open_the_way_in! + Location::Interior.entry_room(place.reload) + end + + test "a moved doorway keeps its lock and its key on both sides" do + place, landing, key, = keyed_cellar + + entry = lay_out_and_open!(place) + + [ [ landing, entry ], [ entry, landing ] ].each do |from, to| + edge = LocationConnection.find_by!(location: from, connected_location: to) + assert_equal [ "keyed", key ], [ edge.barrier, edge.key_template ], "#{from.name} -> #{to.name}" + end + end + + test "a moved doorway keeps a one-way hazard on the side that had it" do + place, landing, = keyed_cellar + + entry = lay_out_and_open!(place) + + assert_equal [ "drop", 4 ], LocationConnection.find_by!(location: landing, connected_location: entry).slice(:hazard, :hazard_die).values + assert_equal [ nil, nil ], LocationConnection.find_by!(location: entry, connected_location: landing).slice(:hazard, :hazard_die).values + end + + # A SIDE NEVER WRITTEN is made from the way back: its lock and key, and no + # hazard, since a hazard is paid in one direction. + test "a half-written doorway is moved whole, its missing side locked like the other and free of its hazard" do + place, landing, key, _, up = keyed_cellar + up.destroy! + + entry = lay_out_and_open!(place) + + back = LocationConnection.find_by!(location: entry, connected_location: landing) + assert_equal [ "keyed", key, nil ], [ back.barrier, back.key_template, back.hazard ] + end + + test "a moved doorway keeps every game's opening through it, ids and all" do + place, landing, key, down, = keyed_cellar + first = create(:playthrough, story: @story, current_location: landing) + second = create(:playthrough, story: @story, current_location: landing) + opened = Playthrough::Passage.open!(first, down, means: "key", item: first.items.find_by!(template: key)) + + entry = lay_out_and_open!(place) + + moved = Playthrough::Passage.where(id: opened.map(&:id)).order(:id) + assert_equal opened.map(&:id), moved.map(&:id) + assert_equal [ [ landing, entry ], [ entry, landing ] ], + moved.map { |passage| [ passage.location_connection.location, passage.location_connection.connected_location ] } + assert(moved.all? { |passage| passage.playthrough == first && passage.means == "key" }) + assert LocationConnection.find_by!(location: landing, connected_location: entry).open_for?(first) + assert_not LocationConnection.find_by!(location: landing, connected_location: entry).open_for?(second) + end + + test "a toll paid crossing a moved doorway names the doorway it moved to" do + place, landing, _, down, = keyed_cellar + game = create(:playthrough, :started, story: @story) + toll = create(:playthrough_toll, playthrough: game, location: place, hazard: "drop", location_connection: down) + + entry = lay_out_and_open!(place) + + assert_equal LocationConnection.find_by!(location: landing, connected_location: entry), toll.reload.location_connection + end + # A PLACE WITH AN INSIDE IS ASKED FOR NO WAYS OUT, and a fake with one queued # answer is what proves the call was never made: a second ask raises. test "a place that has been laid out is asked for no ways out of its own" do diff --git a/test/models/location/one_room_fold_test.rb b/test/models/location/one_room_fold_test.rb index 8e89b1c5..473a4f78 100644 --- a/test/models/location/one_room_fold_test.rb +++ b/test/models/location/one_room_fold_test.rb @@ -120,6 +120,14 @@ def fold = Location::OneRoomFold.candidates(@story).sole assert_match(/never written out/, fold.reasons.join) end + test "a floor of one room is laid out so on purpose, and is not folded" do + create(:floor, location: @place, rooms: 1) + + fold = Location::OneRoomFold.candidates(@story).sole + assert_not fold.proven? + assert_match(/Core Access Chamber is a floor/, fold.reasons.join) + end + test "a single room with a name of its own is somebody's floor plan, not a candidate" do @room.update!(name: "The Frost Gallery") diff --git a/test/models/playthrough/sqlite_package_test.rb b/test/models/playthrough/sqlite_package_test.rb index aa8c86dc..35ca7951 100644 --- a/test/models/playthrough/sqlite_package_test.rb +++ b/test/models/playthrough/sqlite_package_test.rb @@ -16,6 +16,8 @@ class Playthrough::SqlitePackageTest < ActiveSupport::TestCase distance: "adjacent", travel_method: "walking") create(:location_connection, location: @hall, connected_location: @opening, distance: "adjacent", travel_method: "walking") + @cellars = create(:location, :stub, story: @story, name: "The Cellars", width: 12, depth: 12) + create(:floor, location: @cellars, rooms: 4, danger: "uneasy") @hero = create(:character, story: @story, fullname: "Ada Package", is_protagonist: true, location: @opening) other_story = create(:story, title: "Other World Not Packaged") @@ -81,6 +83,7 @@ class Playthrough::SqlitePackageTest < ActiveSupport::TestCase connection.execute("DELETE FROM characters_scenes WHERE scene_id IN (SELECT id FROM scenes WHERE story_id IN (#{list}))") connection.execute("DELETE FROM scenes WHERE story_id IN (#{list})") connection.execute("DELETE FROM location_connections WHERE location_id IN (SELECT id FROM locations WHERE story_id IN (#{list}))") + connection.execute("DELETE FROM floors WHERE location_id IN (SELECT id FROM locations WHERE story_id IN (#{list}))") connection.execute("DELETE FROM locations WHERE story_id IN (#{list})") connection.execute("DELETE FROM characters WHERE story_id IN (#{list})") connection.execute("DELETE FROM stories WHERE id IN (#{list})") @@ -122,8 +125,10 @@ class Playthrough::SqlitePackageTest < ActiveSupport::TestCase assert_equal "Package Fixture Story", Story.sole.title assert_equal 1, Playthrough.count assert_equal @playthrough.id, Playthrough.sole.id - assert_equal [ "The Foyer", "The Hall" ].sort, Location.order(:name).pluck(:name) + assert_equal [ "The Cellars", "The Foyer", "The Hall" ], Location.order(:name).pluck(:name) assert_nil Location.find_by(name: "Should Not Appear") + # A floor goes with the place it is. + assert_equal [ [ "The Cellars", 4, "uneasy" ] ], Floor.includes(:location).map { |floor| [ floor.location.name, floor.rooms, floor.danger ] } assert_equal 1, Item.where(playthrough_id: nil).count assert_equal 1, Item.where(playthrough_id: @playthrough.id).count assert_equal "/look", Playthrough::Command.sole.command diff --git a/test/models/story/doctor_test.rb b/test/models/story/doctor_test.rb index 3315d4d8..e431d2bf 100644 --- a/test/models/story/doctor_test.rb +++ b/test/models/story/doctor_test.rb @@ -2256,6 +2256,98 @@ def door(one, other, travel_method: "walking") assert_not_includes codes(story), :door_between_rooms_that_share_no_wall end + # --- floors --------------------------------------------------------------- + # + # A FLOOR LAID OUT AS THE ENGINE LAYS ONE (`Floor`): its four rooms on storey + # 0 inside its 12x12 footprint, the way in on the entry and the way down on + # the second room. Drawn here by hand so each test can break one thing. + def laid_out_floor + story = WorldSeed::Loader.new(WorldSeed.parse(Rails.root.join("lib/engine_sweep/worlds/two-floors-and-a-key.yml").read)).load! + upper, lower = [ "The Upper Floor", "The Lower Floor" ].map { |name| story.locations.find_by!(name: name) } + rooms = [ [ 0, 0 ], [ 6, 0 ], [ 6, 6 ], [ 0, 6 ] ].each_with_index.map do |(x, y), index| + create(:location, :stub, story: story, name: "The Upper Floor room #{index + 1}", parent_location: upper, + x: x, y: y, z: 0, width: 6, depth: 6) + end + rooms.each_cons(2) { |one, other| connect(one, other) } + LocationConnection.where(location: upper).or(LocationConnection.where(connected_location: upper)).delete_all + connect(rooms.first, story.locations.find_by!(name: "The Landing"), travel_method: "taking stairs") + connect(rooms.second, lower, travel_method: "taking stairs") + [ story, upper, rooms ] + end + + def floor_codes(story) = codes(story).grep(/\Afloor_/) + + test "a floor laid out as the engine lays one is not a finding" do + story, = laid_out_floor + + assert_empty floor_codes(story) + end + + test "a floor unlaid out is not a finding" do + story = WorldSeed::Loader.new(WorldSeed.parse(Rails.root.join("lib/engine_sweep/worlds/two-floors-and-a-key.yml").read)).load! + + assert_empty floor_codes(story) + end + + # A re-seed that changed the count after the floor was laid out. + test "a laid-out floor whose count changed after it was laid out is reported and never repaired" do + story, upper, = laid_out_floor + upper.floor.update!(rooms: 9) + + found = finding(story, :floor_laid_out_to_another_count) + assert_equal [ :warning, :manual, upper ], [ found.severity, found.remedy, found.subject ] + assert_match(/a floor of 9 room\(s\) and was laid out with 4/, found.message) + end + + test "a floor whose way down is not a floor is reported" do + story, upper, = laid_out_floor + upper.floor.update_column(:down_location_id, story.locations.find_by!(name: "The Old Cellar").id) + + assert_match(/whose way down is The Old Cellar, which is not a floor/, finding(story, :floor_going_down_to_no_floor).message) + end + + test "a room of a floor the engine could not have laid out is reported" do + story, _, rooms = laid_out_floor + rooms.last.update!(z: 1) + + assert_match(/The Upper Floor room 4 is inside The Upper Floor, a floor, and not on its storey 0/, + finding(story, :floor_with_a_hand_drawn_room).message) + + rooms.last.update!(z: 0, x: 12) + assert_includes floor_codes(story), :floor_with_a_hand_drawn_room + end + + test "a laid-out floor none of whose rooms leads down is reported" do + story, _, rooms = laid_out_floor + lower = story.locations.find_by!(name: "The Lower Floor") + LocationConnection.where(location: rooms.second, connected_location: lower).delete_all + + assert_match(/whose way down is The Lower Floor, and none of its rooms leads there/, + finding(story, :floor_with_no_way_down).message) + end + + # The way down may land on a room of the floor below, once that is laid out. + test "a way down onto a room of the floor below is a way down" do + story, _, rooms = laid_out_floor + lower = story.locations.find_by!(name: "The Lower Floor") + below, beyond = [ 0, 6 ].each_with_index.map do |x, index| + create(:location, :stub, story: story, name: "The Lower Floor room #{index + 1}", parent_location: lower, + x: x, y: 0, z: 0, width: 6, depth: 6) + end + connect(below, beyond) + LocationConnection.where(location: rooms.second, connected_location: lower).update_all(connected_location_id: below.id) + + assert_empty floor_codes(story) + end + + test "a floor whose way down is on its entry is reported" do + story, _, rooms = laid_out_floor + connect(rooms.first, story.locations.find_by!(name: "The Lower Floor"), travel_method: "taking stairs") + + assert_match(/entry room The Upper Floor room 1 leads down to The Lower Floor/, + finding(story, :floor_way_down_on_its_entry).message) + end + # A BUILDING WRITTEN OUT IN FULL WITH NOTHING INSIDE IT. The layout and the # flip to `realized` are one transaction, so a realized place with a footprint # has rooms -- see `Location::Generator#lay_out_interior!`. diff --git a/test/models/story/fork_test.rb b/test/models/story/fork_test.rb index a219f8ff..c3e7b71a 100644 --- a/test/models/story/fork_test.rb +++ b/test/models/story/fork_test.rb @@ -113,6 +113,19 @@ class Story::ForkTest < ActiveSupport::TestCase assert_equal forked.universe_id, forked.characters.first.race.universe_id end + # A FLOOR GOES WITH THE PLACE IT IS: its count, its words and its way down, + # through the snapshot the fork is loaded from. + test "the fork's floors are the original's" do + story = WorldSeed::Loader.new(WorldSeed.parse(Rails.root.join("lib/engine_sweep/worlds/two-floors-and-a-key.yml").read)).load! + Story::Snapshot.capture!(story) + + forked = Story::Fork.new(story).create! + + floors = ->(of) { Floor.where(location: of.locations).includes(:location, :down_location).order(:id).map { |floor| [ floor.location.name, floor.rooms, floor.danger, floor.hazard, floor.down_location&.name ] } } + assert_equal 2, floors.call(forked).size + assert_equal floors.call(story), floors.call(forked) + end + test "a fork can be forked, because it is snapshotted as it is written" do story = seeded_world forked = Story::Fork.new(story).create!