diff --git a/app/models/story/doctor.rb b/app/models/story/doctor.rb index de24d3a0..930506f9 100644 --- a/app/models/story/doctor.rb +++ b/app/models/story/doctor.rb @@ -1866,7 +1866,9 @@ def hazards # P2 THE FRONTIER KEEPS POINTING AT THE GOAL -- not every unexplored exit # leads away from it. `#frontier_turned_away_from_the_goal`. # P3 THE ARC CAN COMPLETE -- every bound step is reachable from the opening - # room. `#unreachable_quest_targets`, and it is FATAL. + # room, by a walk that opens a locked door only once its key (or a + # lockpick) has been reached. `#unreachable_quest_targets`, and it is + # FATAL; `#locks_whose_key_lies_behind_them` names the door. # P4 THE ENDING IS WRITTEN -- there is an outcome row to reach. # `#quests_without_an_outcome`. # @@ -1885,7 +1887,8 @@ def arc [ *stories_without_a_conclusion, *quests_without_an_outcome, *quests_with_two_defaults, *outcomes_nothing_can_reach, *unbound_quest_steps, *companions_from_elsewhere, *stories_that_cannot_progress, - *missing_quest_targets, *unreachable_quest_targets, *frontier_turned_away_from_the_goal, + *missing_quest_targets, *unreachable_quest_targets, *locks_whose_key_lies_behind_them, + *frontier_turned_away_from_the_goal, *due_endings_out_of_place ] end @@ -2294,46 +2297,157 @@ def unbound_remedy(quest) "This file declares no such row, so the arc names something the world file does not contain" end + # P3's OTHER HALF: A LOCK WHOSE KEY CANNOT BE HAD WITHOUT GOING THROUGH IT. + # Until this check the walk below crossed every connection row, locked or + # not, so a world whose key lay behind its own door read as completable. Now + # the walk opens a keyed door only once something that opens it has been + # reached (`#reachable_from_the_opening`), and this names each door that + # walk leaves shut with its far side still out of reach -- the reason + # `#unreachable_quest_targets` fires, when it does, said about the door + # rather than about the target. + # + # A WARNING AND NOT FATAL, deliberately. Rooms nobody can enter are content + # nobody plays, which is worth knowing; whether the world can still be + # FINISHED is P3's own question, and when an arc target is behind the door + # `quest_target_unreachable` is already fatal about it. + # + # A DOOR WHOSE NEAR SIDE IS ITSELF OUT OF REACH IS NOT REPORTED: it is behind + # some other lock, and that lock is the one somebody has to fix first. + # + # The graph idea (a key must be reachable without passing the lock it opens, + # iterated to a fixed point) comes from Scouten2/LevelDesignSkill's + # `graph_check.py` (MIT); this is a reimplementation over this world's own + # rows, not a port of that code. + def locks_whose_key_lies_behind_them + return [] if play_location.nil? + + reached = reachable_from_the_opening.to_set + shut = lock_walk.fetch(:edges).flat_map do |from, exits| + next [] unless reached.include?(from) + + exits.filter_map { |to, row| row if !reached.include?(to) && row.barrier == "keyed" } + end + shut.uniq { |row| [ [ row.location_id, row.connected_location_id ].sort, row.key_template_id ] } + .sort_by(&:id).map do |row| + key = row.key_template + finding(:lock_whose_key_lies_behind_it, :warning, + "The way from #{row.location.name.inspect} to #{row.connected_location.name.inspect} is locked, " \ + "#{where_the_key_is(key, row)}, and nothing reachable from #{play_location.name.inspect} picks a lock -- " \ + "so nobody can ever open it", + :manual, subject: row) + end + end + + # WHY THE KEY IS OUT OF REACH, in the three shapes it takes: behind this very + # door, behind some other lock nobody can open either (keys behind each + # other's doors), or nowhere a walk could find it at all. + def where_the_key_is(key, row) + room = room_of_item(key) + return "and its key #{key&.name.inspect} lies nowhere anybody can reach" if room.nil? || room == :in_hand + return "and its key #{key.name.inspect} lies in #{room.name.inspect}, on the far side of that same door" if + lock_walk_from(play_location.id, keys: lock_walk.fetch(:keys), opened: row).include?(room.id) + + "and its key #{key.name.inspect} lies in #{room.name.inspect}, behind another lock whose own key is out of reach too" + end + # EVERY LOCATION THE PLAYER CAN WALK TO FROM WHERE THE GAME OPENS, breadth - # first over the connection rows -- both directions, because a door is two - # rows and a walk crosses one the table only half recorded. Stairs are - # ordinary rows, so a storey is walked like anything else. - # - # Computed once for the whole run: three checks ask it and a second walk would + # first over the connection rows, and NOW LOCK-AWARE: an open or a jammed + # door is crossed (a jammed one can always be forced -- the `force` choice in + # `Playthrough::PhysicalAction` needs no tool), a keyed one only once its + # key, or any lockpick, lies somewhere already reached. Picking up a key can + # open a door that leads to the next key, so the walk is repeated until the + # keys in hand stop growing -- a fixed point, and the reason a key behind its + # own lock, or two keys behind each other's, never opens anything. + # + # BOTH DIRECTIONS, because a door is two rows and a walk crosses one the table + # only half recorded: a direction with its own row obeys that row, and a + # direction with none obeys the row the other way. Stairs are ordinary rows, + # so a storey is walked like anything else. + # + # Computed once for the whole run: two checks ask it and a second walk would # be a second answer waiting to disagree. def reachable_from_the_opening - @reachable_from_the_opening ||= begin + lock_walk.fetch(:reached) + end + + def lock_walk + @lock_walk ||= begin + edges = lock_edges root = play_location - root.nil? ? [] : walk_from(root.id).keys + reached = [] + keys = Set.new + unless root.nil? + loop do + keys = openers_in(reached) + walked = lock_walk_from(root.id, keys: keys, edges: edges) + break if walked.sort == reached.sort + + reached = walked + end + end + { reached: reached, keys: keys, edges: edges } end end - # `{ location id => hops }`, out from one room. Undirected, and neighbours are - # taken in id order so the walk is repeatable. - def walk_from(root_id) - seen = { root_id => 0 } + # The location ids one walk reaches holding `keys` (key template ids, plus + # `:lockpick` when one is in reach), optionally with one more door `opened`. + # Neighbours in id order, so the walk is repeatable. + def lock_walk_from(root_id, keys:, edges: lock_walk.fetch(:edges), opened: nil) + seen = Set[root_id] queue = [ root_id ] - until queue.empty? id = queue.shift - adjacency.fetch(id, []).each do |neighbour| - next if seen.key?(neighbour) + edges.fetch(id, []).each do |neighbour, row| + next if seen.include?(neighbour) + next unless row == opened || passable?(row, keys) - seen[neighbour] = seen[id] + 1 + seen << neighbour queue << neighbour end end + seen.to_a + end - seen + def passable?(row, keys) + row.barrier != "keyed" || keys.include?(:lockpick) || keys.include?(row.key_template_id) end - def adjacency - @adjacency ||= LocationConnection.joins(:location).where(locations: { story_id: story.id }) - .pluck(:location_id, :connected_location_id) - .each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(from, to), index| - index[from] << to - index[to] << from - end.transform_values { |ids| ids.uniq.sort } + # `{ from id => [[to id, the row that governs that direction]] }`. + def lock_edges + rows = LocationConnection.joins(:location).where(locations: { story_id: story.id }) + .includes(:location, :connected_location, :key_template).order(:id).to_a + recorded = rows.to_set { |row| [ row.location_id, row.connected_location_id ] } + index = Hash.new { |hash, key| hash[key] = [] } + rows.each do |row| + index[row.location_id] << [ row.connected_location_id, row ] + index[row.connected_location_id] << [ row.location_id, row ] unless recorded.include?([ row.connected_location_id, row.location_id ]) + end + index.transform_values { |pairs| pairs.sort_by { |to, row| [ to, row.id ] }.uniq(&:first) } + end + + # WHAT OPENS A LOCK, from the world's own templates: the key ids that lie in + # a reached room (or in somebody's hands who stands in one, or in the + # protagonist's from the start), and `:lockpick` once any lockpick does. + def openers_in(reached) + reached = reached.to_set + tools = Item.in_story(story).templates.where(use_kind: %w[key lockpick]).includes(:location, :character, :within) + tools.each_with_object(Set.new) do |item, held| + room = room_of_item(item) + next unless room == :in_hand || (room && reached.include?(room.id)) + + held << (item.use_kind == "lockpick" ? :lockpick : item.id) + end + end + + # Where a template can be picked up: its room, its holder's room, the room of + # whatever it rests in, `:in_hand` for the protagonist's own (whose position + # is the playthrough's, not the story's), or nil for nowhere at all. + def room_of_item(item, depth = 0) + return nil if item.nil? || depth > 8 + return item.location if item.location + return (item.character.is_protagonist? ? :in_hand : item.character.location) if item.character + + room_of_item(item.within, depth + 1) end def geometry diff --git a/lib/engine_sweep/worlds/a-key-locked-in-with-its-door.yml b/lib/engine_sweep/worlds/a-key-locked-in-with-its-door.yml new file mode 100644 index 00000000..edda3ae4 --- /dev/null +++ b/lib/engine_sweep/worlds/a-key-locked-in-with-its-door.yml @@ -0,0 +1,127 @@ +# A world nobody can finish, on purpose: every key here lies behind a lock it +# is needed to open, and there is no lockpick. It is what +# `Story::Doctor#locks_whose_key_lies_behind_them` is about, in the two shapes +# it reports -- +# +# THE VAULT KEY LIES IN THE VAULT, behind the one door it opens. +# THE ARCHIVE KEY LIES IN THE CHAPEL AND THE CHAPEL KEY IN THE ARCHIVE, so +# each is behind the other's door -- a cycle no walk can break into. +# +# The arc's one beat is to reach the vault, so `quest_target_unreachable` is +# fatal about it too. Before the doctor's walk was lock-aware it crossed every +# row, locked or not, and called this world completable. +# +# No script walks it: a script is played against goldens the engine vendors, +# and this world exists to be diagnosed rather than played +# (`test/models/story/doctor_locks_test.rb` loads it, and `EngineSweepTest` +# lets it carry exactly those two findings). +format: 2 +universe: + physics: Ordinary physics. + technology: Keys, locks and lanterns. + weapons: Wooden staves. + geographies: A gatehouse, a vault, an archive and a chapel. + civilizations: A small walled town. + history: The town keeps its records behind locked doors. + economics: Goods traded for coins. + politics: A town council keeps the peace. + religion: The town keeps its own festivals. + races: + - name: Townsfolk + description: Ordinary people of the town. +story: + title: A Key Locked In With Its Door + genre: town adventure + start_time: '2026-11-02T12:00:00Z' + preface: Every door out of the gatehouse is locked. + summary: Ilse looks for a way into the vault and finds every key on the wrong side of a door. +opening_scene: + location: Gatehouse + characters: [Ilse] + description: You stand in the gatehouse before three locked doors. + summary: Ilse enters the gatehouse. +characters: +- fullname: Ilse + nickname: Ilse + race: Townsfolk + age: 30 + sex: female + is_protagonist: true + is_companion: false + backstory: A clerk sent to fetch the town charter from the vault. + personality: Patient and practical. + appearance: A grey clerk's coat. + likes: Orderly shelves. + dislikes: Lost keys. + fears: Being shut out. + stats: {level: 3, hit_die: 8, strength: 10, dexterity: 12, will: 12} +locations: +- name: Gatehouse + detail_level: realized + population: nobody + opening: true + description: A stone gatehouse with three locked doors. + teaser: A gatehouse. + lore: The town's keys were once kept here. +- name: Vault + detail_level: realized + population: nobody + description: A low vault where the town charter is kept. + teaser: A locked vault. + lore: Its key was left inside when the door last swung shut. + items: + - name: vault key + description: The heavy iron key to the vault. + properties: '{}' + use_kind: key +- name: Archive + detail_level: realized + population: nobody + description: Shelves of ledgers under a dusty skylight. + teaser: A locked archive. + lore: The chapel key hangs here. + items: + - name: chapel key + description: The small brass key to the chapel. + properties: '{}' + use_kind: key +- name: Chapel + detail_level: realized + population: nobody + description: A narrow chapel with one candle burning. + teaser: A locked chapel. + lore: The archive key was left on the altar. + items: + - name: archive key + description: The long steel key to the archive. + properties: '{}' + use_kind: key +connections: +- between: [Gatehouse, Vault] + distance: adjacent + travel_method: walking + barrier: keyed + key_template: vault key +- between: [Gatehouse, Archive] + distance: adjacent + travel_method: walking + barrier: keyed + key_template: archive key +- between: [Gatehouse, Chapel] + distance: adjacent + travel_method: walking + barrier: keyed + key_template: chapel key +quests: +- title: The Town Charter + premise: Fetch the town charter from the vault. + status: open + contributes: true + steps: + - summary: Reach the vault. + trigger: reach_location + target: Vault + outcomes: + - name: fetched + summary: Ilse brings the charter out of the vault. + default: true diff --git a/test/lib/engine_sweep_test.rb b/test/lib/engine_sweep_test.rb index 09067fa4..c454b4d8 100644 --- a/test/lib/engine_sweep_test.rb +++ b/test/lib/engine_sweep_test.rb @@ -74,11 +74,17 @@ class EngineSweepTest < ActiveSupport::TestCase WAITING_ARCS = %w[a-bell-nobody-has-rung.yml a-yard-before-the-winter.yml].freeze WAITING_FINDINGS = %i[quest_step_unbound story_cannot_progress].freeze + # AND ONE WORLD IS UNFINISHABLE ON PURPOSE: every key in it lies behind a + # lock it is needed to open, which is what it exists for the doctor to say + # (`Story::DoctorLocksTest`). Those two findings are all it may carry. + LOCKED_IN = { "a-key-locked-in-with-its-door.yml" => %i[lock_whose_key_lies_behind_it quest_target_unreachable] }.freeze + test "a world of the sweep's own loads and is healthy" do Dir.glob(EngineSweep::WORLDS.join("*.yml")).sort.each do |path| story = WorldSeed::Loader.new(WorldSeed.parse(File.read(path)), source: path).load! findings = Story::Doctor.new(story).findings.reject { |finding| %i[no_arc room_without_a_layout].include?(finding.code) } findings = findings.reject { |finding| WAITING_FINDINGS.include?(finding.code) } if WAITING_ARCS.include?(File.basename(path)) + findings = findings.reject { |finding| LOCKED_IN.fetch(File.basename(path), []).include?(finding.code) } assert_empty findings, "#{File.basename(path)}: #{findings.map(&:message).join("; ")}" end diff --git a/test/lib/seeded_worlds_test.rb b/test/lib/seeded_worlds_test.rb index 6eb00be9..37d81591 100644 --- a/test/lib/seeded_worlds_test.rb +++ b/test/lib/seeded_worlds_test.rb @@ -144,7 +144,7 @@ class SeededWorldsTest < ActiveSupport::TestCase PLAYABLE_ARC_FINDINGS = %i[ no_arc story_without_a_conclusion quest_without_an_outcome quest_with_two_default_outcomes outcome_nothing_can_reach quest_step_unbound story_cannot_progress quest_target_missing - quest_target_unreachable frontier_turned_away_from_the_goal + quest_target_unreachable lock_whose_key_lies_behind_it frontier_turned_away_from_the_goal ].freeze test "every checked-in world has an arc it can be finished by" do diff --git a/test/models/story/doctor_locks_test.rb b/test/models/story/doctor_locks_test.rb new file mode 100644 index 00000000..981904d4 --- /dev/null +++ b/test/models/story/doctor_locks_test.rb @@ -0,0 +1,113 @@ +require "test_helper" + +# P3 WITH THE LOCKS IN: the doctor's walk from the opening room opens a keyed +# door only once its key, or any lockpick, has been reached without it. A key +# behind its own door, or two behind each other's, opens nothing, and the +# door is named (`lock_whose_key_lies_behind_it`) as well as the target it +# strands (`quest_target_unreachable`). `Story::Doctor#locks_whose_key_lies_behind_them`. +class Story::DoctorLocksTest < ActiveSupport::TestCase + def setup + @story = create(:story) + @hall = create(:location, :realized, story: @story, name: "Hall") + @vault = create(:location, :realized, story: @story, name: "Vault") + @quest = create(:quest, story: @story, title: "The Charter") + create(:quest_outcome, :default, quest: @quest, name: "fetched") + step = create(:quest_step, :reach_location, quest: @quest, position: 1, target_name: "Vault") + step.bind!(@vault, at: @story.start_time) + end + + def findings = Story::Doctor.new(@story.reload).findings + def codes = findings.map(&:code) + + test "a key lying behind the only door it opens strands what is behind it" do + key = key_in(@vault, "vault key") + lock!(@hall, @vault, key) + + assert_includes codes, :quest_target_unreachable + lock = findings.find { |finding| finding.code == :lock_whose_key_lies_behind_it } + assert lock, "the door is named" + assert_equal :warning, lock.severity + assert_match "on the far side of that same door", lock.message + assert_not_predicate Story::Doctor.new(@story), :playable? + end + + test "a key lying on the near side opens the door" do + lock!(@hall, @vault, key_in(@hall, "vault key")) + + assert_not_includes codes, :quest_target_unreachable + assert_not_includes codes, :lock_whose_key_lies_behind_it + end + + test "a key in the protagonist's own hands opens the door from the start" do + hero = create(:character, story: @story, is_protagonist: true, location: nil) + key = create(:item, character: hero, name: "vault key", use_kind: "key") + lock!(@hall, @vault, key) + + assert_not_includes codes, :lock_whose_key_lies_behind_it + end + + test "a chain of keys is followed to its end, each behind the door before it" do + cellar = create(:location, :realized, story: @story, name: "Cellar") + first = key_in(@hall, "cellar key") + second = key_in(cellar, "vault key") + lock!(@hall, cellar, first) + lock!(cellar, @vault, second) + + assert_not_includes codes, :quest_target_unreachable + assert_not_includes codes, :lock_whose_key_lies_behind_it + end + + test "two keys behind each other's doors are a cycle nobody can break into" do + archive = create(:location, :realized, story: @story, name: "Archive") + archive_key = key_in(@vault, "archive key") + vault_key = key_in(archive, "vault key") + lock!(@hall, @vault, vault_key) + lock!(@hall, archive, archive_key) + + locks = findings.select { |finding| finding.code == :lock_whose_key_lies_behind_it } + assert_equal 2, locks.size + assert locks.all? { |finding| finding.message.include?("behind another lock") } + assert_includes codes, :quest_target_unreachable + end + + test "a lockpick in reach opens any lock, as the engine's pick choice does" do + lock!(@hall, @vault, key_in(@vault, "vault key")) + create(:item, :lying, location: @hall, character: nil, name: "bent wire", use_kind: "lockpick") + + assert_not_includes codes, :quest_target_unreachable + assert_not_includes codes, :lock_whose_key_lies_behind_it + end + + test "a jammed door can always be forced, so it strands nothing" do + connect!(@hall, @vault, barrier: "jammed") + + assert_not_includes codes, :quest_target_unreachable + end + + test "the sweep world built to trip it does" do + story = WorldSeed::Loader.new(YAML.safe_load_file(EngineSweep::WORLDS.join("a-key-locked-in-with-its-door.yml"))).load! + found = Story::Doctor.new(story).findings + locks = found.select { |finding| finding.code == :lock_whose_key_lies_behind_it } + + assert_equal 3, locks.size + assert_equal 1, locks.count { |finding| finding.message.include?("far side of that same door") } + assert_equal 2, locks.count { |finding| finding.message.include?("behind another lock") } + assert_includes found.map(&:code), :quest_target_unreachable + assert_not_predicate Story::Doctor.new(story), :playable? + end + + private + + def key_in(room, name) + create(:item, :lying, location: room, character: nil, name: name, use_kind: "key") + end + + def lock!(from, to, key) = connect!(from, to, barrier: "keyed", key_template: key) + + def connect!(from, to, **barrier) + [ [ from, to ], [ to, from ] ].each do |one, other| + LocationConnection.create!(location: one, connected_location: other, **barrier, + distance: LocationConnection::DISTANCES.keys.first, travel_method: "walking") + end + end +end