Skip to content

Lay out a floor to its own count, and keep a moved doorway's lock - #94

Merged
calebl merged 3 commits into
mainfrom
fm/ta-floors-s2-engine
Oct 30, 2026
Merged

calebl merged 3 commits into
mainfrom
fm/ta-floors-s2-engine

Conversation

@calebl

@calebl calebl commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Stage 2 of the dungeon plan, engine half: floor layout. A place its world gives a floors row is laid out by the engine as exactly that many rooms on one storey, with a way down rolled onto a room that is never the entry. It is laid out once, the first time anybody reaches it, and every later game in the world reads the stored rooms. Doorways moved onto a place's rooms now keep their lock, key and hazard. That carry-over applies to every place, not only floors. A world with no floors row lays out, plays and prompts byte for byte as before.

What changed

  • floor.rs (new, pure). floor::lay_out(place, floor) returns a Layout and the way-down room index.

    • Grid. Columns = ⌈√rooms⌉ and rows = ⌈rooms ÷ columns⌉, on storey 0. The existing serpentine is walked and its first rooms cells are kept, so the last line may be short. Column widths and row depths come from the existing share.
    • Footprint. The place's own footprint is used when each side holds the grid at MINIMUM_SIDE paces a room. Otherwise it is floor::footprint(rooms) = (6 × columns, 6 × rows).
    • Doorways. The snake is the backbone, then the existing extra-door die (2 in 6), close_connectivity and the four-exit cap.
    • Way down. Rolled after the backbone and before the extra doors, from rooms 1…n−1 (never the entry). Like the entry, that room keeps one doorway spare.
    • Danger and hazard. Every room's danger and hazard come from the floor's own danger / hazard words through the existing danger::in_a_place / hazard_in_a_place, with a flat gradient. The model's picks are never read for a floor.
    • Kind. Room sorts are dealt by kind::deal from the floor place's own kind when that is a building word. Otherwise the model's place_kind deals them, as before.
    • Seed and order. Every draw comes from the place's own Roll::INTERIOR generator, in this order: widths, depths, serpentine, each room's danger then hazard, way down, extra doors.
    • Room count. At least 1, at least 2 when there is a way down, and at most MOST_ROOMS = 1000.
  • interior.rs. The Builder is opened to the crate (Builder::new, grid, way_down) so the floor path reuses its pieces. No draw moved: the 360 vectors/interior.json cases and all 100 existing goldens and records files are unchanged.

  • realize.rs lay_out. When the place has a floors row, it takes the floor path and passes the way-down room and the floor's down_location_id to open_the_way_in. Otherwise it takes the interior path exactly as before.

  • open_the_way_in: routing. The doorway whose far end is the floor's down place, or a room of it (when the lower floor was laid out first), opens onto the way-down room. Every other doorway opens onto the doorstep (entry first), and the doorstep no longer includes the way-down room.

  • open_the_way_in: carry-over, for every place. A moved doorway keeps, side by side:

    • barrier and key_template_id;
    • hazard and hazard_die on the side that had them, so a one-way hazard keeps its direction;
    • every game's playthrough_passages through it (re-inserted onto the new row, ids kept, as a shuffle does);
    • every game's playthrough_tolls that named it (re-pointed to the new row).

    Before this change, a moved doorway kept only its distance and travel method. A toll on the old row also made the delete fail its foreign key: playthrough_tolls has no ON DELETE. A doorway that has nowhere to land is still dropped, and its tolls are left naming no doorway.

  • Generated once. Layout still runs only for a place with no rooms (laid_out). A floor reached again, in any game, is read from its rows. Nothing re-lays it out, deals it again or rewrites it, even after its floors row changes.

  • Store. floors is an optionally loaded table (store::OPTIONAL_LOADED, store::OPTIONAL_SHAPES), loaded where the database has it and read as empty where it does not. shape leaves its facts out only when every one of them is there, so a floors table declared any other way, or missing its unique index, is still a difference and is refused. SCHEMA_VERSION and shape.txt do not move, so the game at its current pin still opens.

  • Migration 20261030200000 (CreateFloors) is applied to every world dump in parity/worlds/. Each dump re-dumped byte-identically before the migration was applied. The SQL is exactly what Rails' SQLite adapter issued: I ran the migration below with bin/rails db:migrate on a copy of a world, and its dump was byte-identical to the one my SQL produced.

  • Sweep. New world two-floors-and-a-key and two new scripts, both in PASSING with goldens and records files.

    • The world. The dump is the game's loader at renderedstep/server main 3d357bd loading the world file below without its floor: and floor kind: keys, which that loader cannot read yet. Then the migration and the rows the new loader should write were added:
      • floors rows (Upper: 4 rooms, uneasy, none, down → Lower; Lower: 2 rooms, dangerous, unlit);
      • the floors' footprints (12×12 and 12×6);
      • kind = 'warren'.
    • a-floor-laid-out-to-its-count (14 steps).
      • The place call asks for a few rooms over five storeys, safe and flooded; the floor is still laid out as 4 rooms on storey 0.
      • Room 1 (the entry) leads only to the landing and room 2. Rooms 2→3→4 are walked, and room 4 leads nowhere new, so there are exactly four rooms.
      • The stair down is on room 2, never the entry. It is still locked (offers: locked), and the stair key opens it.
      • The lower floor is laid out to its own two rooms, and the stair lands on its entry.
      • A second game walks the same rooms with no call, and finds the stair locked for itself.
    • a-keyed-stair-onto-a-place-stays-keyed (7 steps, an ordinary place with no floor:). This is the carry-over on its own.
      • Locked before layout. The game unlocks it, then walks down: layout moves the stair, and the game's opening and the one-way d4 drop come with it (hazards: 1).
      • Climbing back is free, and going down again drops again.
      • A second game finds the stair locked.
    • Every expect: in both scripts was checked against the engine's dumps (21 expectations, 0 mismatches). The records show the moved stair's rows: landing→entry keyed, cellar key, drop, 4; entry→landing keyed, cellar key, no hazard.

Tests

  • floor::tests.
    • Every count from 1 to 200 (and 256, 333, 500, 1000), each over up to 12 places, with and without a way down. Each layout has exactly the count, every room reachable from the entry, a way down never the entry and still with a doorway to spare, the entry with a doorway to spare, no room past four exits, every doorway both ways and on a shared wall, every room on storey 0 and at least MINIMUM_SIDE.
    • Clamping: 1 room with a way down → 2; 0 → 1; 5000 → 1000.
    • The same place lays out the same every time.
    • Grid and footprint, including an own footprint kept or replaced.
    • Danger and hazard are drawn only from the floor's words.
    • A warren's rooms are all cave.
  • tests/floors.rs, on the live path with a scripted model.
    • Exactly 4 rooms on storey 0, cave, with uneasy-table dangers and no flood, despite the picks. The footprint is 12×12, no doorway is left on the floor place, and the way in lands on the entry. The way down lands on a room other than the entry and is keyed with the stair key on both sides.
    • Generated once. After the layout, the floors row is rewritten (9 rooms, dangerous, flooded). Then three games, the first among them, walk in. A second game's walk-in makes one call (the arrival). The rooms and their doorways are byte-identical afterwards, and the location count does not move.
    • Carry-over. A model-free game unlocks the cellar and walks into it before layout, paying the drop on the old stair. Another game unlocks it and walks in, laying it out. Both games' passages are now on the two moved rows, and both drops name the new stair down. The sides keep their barrier, key and hazard, and a third game is refused as locked.
  • Store (tests/engine.rs). A database without floors (table dropped, version row removed) opens and plays. A floors table with rooms nullable is refused (found column floors.rooms integer pk=0), and so is one missing its unique index.

Local runs, all with both provider keys unset:

  • cargo fmt --check, cargo clippy --workspace --all-targets --locked -- -D warnings and cargo test --workspace --locked: all pass.
  • parity --check parity: 102 of 106 agree. The other four are runner-only, as before.
  • I also ran the CI runner job locally against the game at SERVER_COMMIT (ccc92ed), with the extension built from this checkout. parity/runner.sh: 104 of 106 agree. a-floor-laid-out-to-its-count and a-keyed-stair-onto-a-place-stays-keyed are "not in the checkout yet". engine:rust_gates: PASSED for 104 scripts.

SERVER_COMMIT does not move: the game at its current pin (ccc92ed) has no floors table, and the engine opens it and plays as before.

What the server pin PR must change

  1. Migration db/migrate/20261030200000_create_floors.rb, with this exact version and declaration so the dumps here stay byte-true (store::OPTIONAL_SHAPES holds every fact of it). db/schema.rb moves to 2026_10_30_200000.

    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
  2. Floor model, with a model test and a dice-free factory.

    • Associations: Floor belongs_to :location and belongs_to :down_location, class_name: "Location", optional: true. Location has_one :floor, dependent: :destroy.
    • Validations: rooms an integer in 1..1000 (floor::MOST_ROOMS), and at least 2 when down_location is set. danger in Location::Parameters' danger keys (safe uneasy dangerous), allow nil. hazard in Location::Parameters::HAZARDS (none flooded unlit silent airless), allow nil. down_location not the floor itself, in the same story, and itself a floor.
    • Location's validates :kind must also accept a Location::Kind::BUILDINGS word on a location that has a floor: a floor's kind: is a building (warren), which deals its rooms' sorts.
  3. Loader (lib/world_seed/loader.rb). This is the shape in plan §1.1. The floor: keys at this stage are only rooms, danger, hazard and down. Refuse any other key under floor:; the later keys arrive with their stages.

    • Refuse:

      • a non-Integer rooms or one outside 1..1000, and rooms < 2 with down;
      • a danger or hazard word outside the tables;
      • a down that is not a floor: location of this file, names itself, is not joined to this floor by a connection, or makes a cycle;
      • a floor with width, depth, x, y or z, or with a detail_level other than stub;
      • any location whose parent: is a floor;
      • a floor whose kind: is not a Location::Kind::BUILDINGS word (validate_kinds! keeps KINDS for every other location).
    • Write:

      • the floors row (find_or_initialize_by(location:)), every key in both directions on every load: an absent key writes nil, and removing floor: deletes the row;
      • while the floor has no rooms, its footprint width = 6 × columns, depth = 6 × rows, where columns is the smallest c with c² ≥ n, rows = ⌈n ÷ c⌉, and n = rooms clamped as in item 2 (floor::footprint).

      A laid-out floor's footprint and rooms are never rewritten. The re-seed rule is principle 7: a re-seed that changes rooms after layout changes the row only.

    • Tests: seeds a floor with its row and footprint; refuses each case above; re-seed after layout leaves the rooms alone.

  4. Exporter (lib/world_seed/exporter.rb). For a location with a floor, emit floor: { rooms, danger, hazard, down }.compact, with down by name. Omit the floor's width/depth (the loader derives them) and its rooms (the engine lays them out). Round-trip test.

  5. Doctor findings (report, never repair):

    • a laid-out floor whose room count differs from its rooms (clamped), for example because a re-seed changed it after layout;
    • a floor whose down is not a floor;
    • a floor carrying a hand-drawn room, as the plan lists it. How a stored world tells one apart is the pin PR's call: for example, a room under a floor that is not on storey 0, or falls outside the floor's footprint;
    • a laid-out floor with a down but no doorway from one of its rooms to down or a room of down, and one whose way down lands on its entry.

    Each finding gets a doctor test.

  6. Ruby Location::Generator#open_the_way_in!: the same carry-over.

    • ways_in reads both directed rows of each doorway.
    • The new rows are written with each side's barrier and key_template, and with hazard / hazard_die on the side that had them. A side that was never written takes the other side's barrier and key with no hazard.
    • Before the old rows go, tolls naming them are set to nil. After the new rows are written, the passages are re-created on the new row of their side, ids kept, and the tolls are re-pointed. A dropped doorway leaves its tolls naming no doorway.
    • With a floor, the doorway to the floor's down place (or a room of it) goes onto the way-down room. The Ruby generator does not lay floors out (the engine does), so this is only the routing rule, if the Ruby path keeps a reader of it.
    • Tests for the carry-over: keyed, one-way hazard, passage and toll.
  7. Copies of a world: Story::Fork, Story::Snapshot and Playthrough::SqlitePackage copy floors with the locations they point at.

  8. Sweep.

    • Copy in lib/engine_sweep/worlds/two-floors-and-a-key.yml and the two scripts lib/engine_sweep/scripts/a-floor-laid-out-to-its-count.yml and a-keyed-stair-onto-a-place-stays-keyed.yml, with their comments and why: notes (below).
    • Vendor this commit's goldens, scripts and test/engine_parity/<script>.* for both.
    • Update test/ruby_loop_coverage.yml if it lists scripts.
    • Then rebuild parity/worlds/two-floors-and-a-key.sql here from that branch's loader, as Let a story set its own people and thing limits #90 did for a-crowded-cellar, so the dump is the loader's own.
  9. Move the pin to this commit. A later engine change may then move SCHEMA_VERSION to 20261030200000 and put floors into shape.txt in place of OPTIONAL_SHAPES.

Out of scope, as the brief says:

  • guards and monster_level (stage 3);
  • the dungeon world file (stage 4);
  • wardens, the deal tables, escalation and every other stage;
  • any prompt change. No prompt template changed, and a world without floors sends byte-identical requests.

No model was called.

lib/engine_sweep/worlds/two-floors-and-a-key.yml
# 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
lib/engine_sweep/scripts/a-floor-laid-out-to-its-count.yml
# 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
lib/engine_sweep/scripts/a-keyed-stair-onto-a-place-stays-keyed.yml
# 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

A place its world gives a `floors` row (migration 20261030200000) is laid
out by the new floor module instead of interior: exactly `rooms` rooms on
one storey, a near-square grid walked as the serpentine, each room's
danger and hazard from the floor's own words through Parameters (the
model's picks are never read for a floor), its sorts dealt from the
floor's own building kind, and a way down rolled from every room but the
entry, which keeps a doorway spare as the entry does. Every draw is the
place's own Roll::INTERIOR generator; interior's Builder is opened to the
crate and no draw of it moved (vectors/interior.json and every existing
golden and records file are unchanged).

open_the_way_in routes the doorway to the floor's `down` place, or to a
room of it, onto the way-down room, and every other doorway onto the
doorstep. For every place, floor or not, a moved doorway now keeps each
side's barrier, key, and hazard (so a one-way hazard keeps its
direction), every game's passages through it, and every toll that named
it; before, it kept only its distance and travel method, and a toll on
the old row failed the delete. A floor is laid out once, as any place
is: a later game, or a changed `floors` row, reads the stored rooms.

The store loads `floors` where the database has it (OPTIONAL_LOADED) and
leaves its facts out of the shape only when the table is whole
(OPTIONAL_SHAPES), so the game at its current pin still opens and a
floors table declared any other way is refused. SCHEMA_VERSION and
shape.txt do not move. Every world dump carries the migration, applied
through sqlite exactly as Rails' SQLite adapter issued it.

New world two-floors-and-a-key (the game's loader at server 3d357bd
without the floor keys, then the migration, the floors rows, their
footprints and kind) and scripts a-floor-laid-out-to-its-count and
a-keyed-stair-onto-a-place-stays-keyed, in PASSING with goldens and
records.
…erver 5678109

The game's pin PR (renderedstep/server#369) reads the floor keys, so the
dump is now the one its own loader writes, floors rows, footprints and
kind included. The rows are the same; only timestamps and the order the
floors table and its constraints are dumped in moved. No golden or
records file moves.
@calebl
calebl merged commit 3846a64 into main Oct 30, 2026
4 checks passed
calebl added a commit to renderedstep/server that referenced this pull request Oct 30, 2026
The merge adds only the engine's CI, its fight simulator, its README and
the two-floors-and-a-key world dump rebuilt from this branch's loader to
the head pinned before; no golden, script or vector portion this
repository vendors changed.
calebl added a commit to renderedstep/server that referenced this pull request Oct 30, 2026
Pin renderedstep/engine#94's head (9a665dd): lay out a floor to its own count, and keep a moved doorway's lock
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant