Skip to content

Let a floor fall in a game when its countdown runs out, and the player on it with it - #98

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

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

Conversation

@calebl

@calebl calebl commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Stage 6 of the dungeon plan, engine half: the countdown and the collapse (D3 b, D9 a).

  • A floor with minutes falls in a game that number of story minutes after that game first stood on it. The deadline is playthrough_floors.arrived_at + floors.minutes.
  • A player standing on it when it falls dies with it. The engine asks twice: before a line is read, and again once the line's story time is spent. A wait (or, from stage 8, a rest) that runs past the deadline is played, never refused, and is fatal.
  • A fallen floor is gone in that game only. Nobody on it is alive in this game, and no way out leads onto it. The world's rows are untouched, so every other game finds the floor whole and runs its own countdown (VISION.md principle 7).
  • The countdown and the collapse are engine copy for the player only. No request changes, and no prompt template changed.

A world without minutes plays byte for byte as before. No model was called; every command ran with OPENROUTER_API_KEY and TYPESAFE_API_KEY unset.

The words

All three are the plan's §4.3 drafts. The only change is that a unit of 1 is singular.

  1. Countdown (Countdown::in_words, on the glance as floor.countdown):

    {floor} falls in {h} hours {m} minutes.
    under an hour: {floor} falls in {m} minutes.

    hour/minute are singular for 1, so: The First Floor falls in 29 minutes., … falls in 1 hour 5 minutes., … falls in 19 hours 12 minutes., … falls in 1 minute. Whole hours keep the minutes: … falls in 2 hours 0 minutes. A part of a minute counts as a whole one, so it never says 0 while the floor stands.

  2. Collapse (Game::collapse_to_s, on the glance as collapse; the death statement follows it unchanged):

    {floor} gave way at {time}, and {fullname} fell with it.

    {time} is the story time the floor fell, as strftime("%-l:%M %P") in UTC (moment::clock_time), for example: The First Floor gave way at 8:30 am, and Odile fell with it.

  3. A fallen floor named (refusal::fallen):

    {floor} has fallen. There is no way back onto it.

    For example: The First Floor has fallen. There is no way back onto it. A refusal on the live path carries the usual Nothing has changed. after it, as every unplayable refusal does.

Where each shows:

  • The glance:
    • floor: {name, falls_at, minutes_left, countdown}, while the player stands on a floor that has a countdown and has not fallen.
    • collapse: the collapse sentence, for a game that ended with the player on a fallen floor. ended (the death statement) is unchanged.
  • Engine alone (no model): a report note, the game: {collapse}, on the line that took the player.
  • The fallen-floor refusal answers a move whose name lands on a way out of here onto a fallen floor, or on that floor itself. On the live path the engine answers it with no reader asked.
  • Never in a request. tests/floor_falls.rs checks every request of a walk past the deadline. The two new records files hold the requests themselves, and none of them contains falls in, gave way or has fallen.

What changed

  • turn.rs Mechanics::floors_fall. Every floor this game reached whose deadline is at or before story time now (and has no fell_at yet) gets fell_at = its deadline.
    • If the room the player stands in is on such a floor, the player is harmed by their remaining hit points (harm, which spills what they held and ends the game) and the game is ended at story time now. A player with no stat block still has the game ended.
    • It writes nothing when the game is over, or when the database has no playthrough_floors.fell_at.
    • Before the line: on the engine-alone path, after catch_up_world. On the told path it is a new journal step, floor_fell, after world_clock. If it took the player, the line is refused in the death statement's words. The engine-alone report also notes the collapse; the live path asks no model.
    • After the time is spent: engine-alone, after answered_by_the_world, noting the collapse. Told path: a new journal step, floor_fell_after, straight after the act's scene is written (so its story_timestamp is spent), and before the tolls are claimed, the riposte, volition and the room's hazard. Nobody strikes a player who has already fallen.
  • playthrough.rs.
    • Game::reached(room): this game's row for the floor a room is on.
    • Game::falls_at(row): arrived_at + minutes × 60.
    • Game::fallen(room), Game::countdown() and Game::collapse_to_s(), plus the Countdown struct and its words.
    • cast_in returns nobody for a room on a fallen floor, so foes_in, followers, the room's cast and the glance's people follow.
    • exits leaves out rooms on a fallen floor, so the dump's exits and the narrator's "Ways out of here" line follow.
  • turn.rs whole_room_of. The room's exits (what the grammar, the classifier, the glance and the slash menu read) leave out a way onto a fallen floor. That way and its floor go into a new Room::fallen list instead (room::Fallen { place, floor }), which is only ever named to be refused.
  • grammar.rs. read_move refuses with the fallen sentence when the name finds no way out but lands on Room::fallen. Grammar::fallen_named(line) does the same for a whole line.
    • told.rs read_line turns that grammar refusal into a declined move intent, so the live path refuses it without asking a reader.
    • Mechanics::refusal_for also checks it for a move the classifier resolved to nothing, so a free-typed line naming the fallen way gets the same words.
  • glance.rs. Glance::floor (glance::Floor) and Glance::collapse.
  • moment.rs. clock_time(at), time_of_day to the minute.
  • refusal.rs. fallen(floor).
  • store.rs.
    • floors.minutes and playthrough_floors.fell_at join OPTIONAL_COLUMNS, so a database with or without them opens.
    • Store::has_column(table, column) is new.
    • SCHEMA_VERSION and shape.txt do not move. The game at its current pin opens and plays as before; nothing there falls.
  • Migration 20261031120000 (AddMinutesToFloorsAndFellAtToPlaythroughFloors), applied to all 34 world dumps in parity/worlds/. I used the same method as stage 5:
    • Each dump was first checked to re-dump byte-identically.
    • Then bin/rails db:migrate ran on a copy of it in a scratch checkout of the game at SERVER_COMMIT (132b475), holding only this migration.
    • The 29 dumps without 20261029154235 had its version row added before the run and deleted after.
    • Each dump now has "minutes" integer on floors (a trailing NULL on each floor row), "fell_at" datetime(6) on playthrough_floors, and the version row.
    • The version is the latest Rails accepts today: it refuses a timestamp more than a day ahead. No later migration is on the game's main (a69297a).
  • README. The playthrough and glance rows of the module table.

Things a reviewer should know

  • The countdown starts at arrived_at. Stage 5 writes arrived_at as story time at the start of the move, before the walk's own minutes (1.5 here). So a 30-minute floor walked onto at 8:00 falls at 8:30, though the arrival scene is stamped 8:01:30.
  • The fallen-floor refusal reaches only names a way out of here leads to. The name of the room beyond, or of its floor, gets the sentence. A fallen floor named from a room with no doorway onto it gets the ordinary "there is no way out called …", as any room out of reach does.
  • The narrator's "Ways out of here: … There are no others." line drops a fallen floor's doorway, because it reads Game::exits. This happens only once a floor has fallen, so every stored baseline replays. Location::Plan's sentences (plan.rs) read the world's doorways, so in that one situation they still mention the stair. I left them alone: the brief rules out any prompt change. It is a candidate for the later narrator stage (D9).
  • fell_at is the deadline, not the minute the engine noticed it. ended_at is the minute it noticed (8:31:30 in the sweep).

What did not move

  • No existing golden or records file.
    • parity --check parity: AGREED 108 of 112. The other four are runner-only, as before.
    • The other floor worlds (two-floors-and-a-key, a-guarded-stair, a-deeper-floor, three-floors-that-raise-you) have no minutes, so nothing in them falls.
    • playthrough_floors is not in parity::WRITTEN_TABLES.
  • fight --check sim: AGREED, 10 fight goldens, unchanged. Nothing was re-blessed. The simulator's worlds have no floors, so nothing can fall in them.

Sweep

New world a-floor-that-falls and two scripts, both in PASSING with a golden and a records file.

How the world was made. I loaded the world file below with the game's loader at SERVER_COMMIT (132b475), without minutes:, which that loader does not read. Then the migration ran, and UPDATE floors SET minutes = 30 on The First Floor wrote what the new loader will write. As with stages 2–5, rebuild the dump from the pin branch's own loader once it reads minutes:.

The world. Odile is level 3 on a d8 (18 hit points). Under the landing are two floors of two rooms each:

  • The First Floor says minutes: 30, and Wenna Cole (no foe) is posted to it, so its layout stands her in the room with the stair down.
  • The Second Floor has no countdown.
  • The story starts at 8:00 am.

waiting-out-a-floor (8 steps):

  1. Onto The First Floor. The walk takes 1.5 story minutes; the countdown started at 8:00.
  2. Then 5 waits of 5 minutes each. She is alive, and the floor stands at 8:26:30.
  3. The 6th wait is played (it is not refused) and carries the clock to 8:31:30. The floor falls at 8:30, she falls with it, and the dump says dead: true.
  4. The next line is refused: Odile is dead, and this playthrough is over. …

a-floor-falls-behind-you (13 steps):

  1. Onto The First Floor, then room 2, where present: [Wenna Cole].
  2. Down onto The Second Floor, whose ways out include The First Floor room 2 (realized).
  3. Five waits: the stair up is still there.
  4. The sixth wait passes the first floor's deadline. She is not on it, so dead: false, and exits_exclude: [The First Floor room 2].
  5. /move The First Floor room 2 and /move The First Floor are both refused: The First Floor has fallen. There is no way back onto it.
  6. A second game walks onto The First Floor and into room 2. Wenna is there and alive, and the stair down leads on.

Wenna being dead in the first game (cast_in) cannot be seen from the sweep, which only dumps the room the player stands in. tests/floor_falls.rs asserts it through Game::cast_in.

The collapse sentence does not show in the sweep: a browser step's note is its token. It is asserted in the tests, on the glance and on the engine-alone note. I checked every expect: against the engine's dumps: 44 expectations, 0 mismatches.

Tests

  • tests/floor_falls.rs (new; live path with replayed replies, plus the engine alone):
    • Waiting it out is fatal and never refused.
      • After the walk in, the glance's floor is {The First Floor, 8:30, 29, "The First Floor falls in 29 minutes."}. After 5 waits it reads … falls in 4 minutes.
      • The 6th wait is played. Then fell_at is 8:30, hp is 0, the game is over, floor is gone, collapse reads The First Floor gave way at 8:30 am, and Odile fell with it., and ended is the death statement.
      • No request of the walk mentions it. The next line is refused as dead.
    • It falls behind a player who went down, in their game only.
      • The first game: fell_at is set, hp stays 18, exits is only The Second Floor room 2, and both lines naming the fallen floor are refused with the exact sentence. The live path refuses them with no reader asked.
      • Wenna: Game::cast_in of her room is empty for the first game, and Game::fallen holds.
      • The world: every location, doorway, character and floor row of The First Floor is byte-identical before and after.
      • A second game finds Wenna alive in room 2, with no fell_at and its own countdown.
    • A countdown already run out takes the player before the line is read. Engine alone, the line is refused as dead with the note the game: The First Floor gave way at 7:30 am, …. On the live path it is refused with no request and the glance carries the collapse.
    • A floor with no minutes never falls, however long ago it was reached.
    • A database from before the migration plays seven waits on the floor, and nothing falls.
    • The words: countdown phrasings for 1, 25, 60, 65, 121 and 1152 minutes, and clock_time at 8:30 am, 1:05 pm and 12:00 am.
  • Store (tests/engine.rs): a database from before the migration opens and plays. A floors.minutes declared as varchar, or a fell_at declared NOT NULL, is refused. every_world_has_the_shape_the_engine_is_written_against holds for all 35 dumps.

Local runs, all with both provider keys unset:

  • cargo fmt --check, cargo clippy --workspace --all-targets --locked -- -D warnings, cargo test --workspace --locked: all pass.
  • parity --check parity: 108 of 112 agree (the four runner-only, as before).
  • fight --check sim: AGREED, 10 fight goldens.
  • CI's runner job, run locally against the game at SERVER_COMMIT (132b475), on its own schema (neither new column), with the extension built from this checkout:
    • parity/runner.sh: 110 of 112 agree; the two new scripts are "not in the checkout yet";
    • engine:rust_gates: PASSED for 110 scripts (goldens, invariants, doctor and audit).

SERVER_COMMIT does not move. The game's extension builds against this checkout unchanged: Glance only gains fields, and the extension reads the ones it knows by name.

What the server pin PR must change

Line numbers are at 132b475.

  1. Migration db/migrate/20261031120000_add_minutes_to_floors_and_fell_at_to_playthrough_floors.rb. Use exactly this version and declaration, so the dumps here stay byte-true. store::OPTIONAL_COLUMNS holds both facts. db/schema.rb moves to 2026_10_31_120000.
    class AddMinutesToFloorsAndFellAtToPlaythroughFloors < ActiveRecord::Migration[8.1]
      def change
        add_column :floors, :minutes, :integer
        add_column :playthrough_floors, :fell_at, :datetime
      end
    end
  2. Models.
    • Floor validates minutes as an integer greater than 0, allowing nil.
    • Playthrough::Floor gains #falls_at (arrived_at + location.floor.minutes.minutes, nil without minutes) and #fallen? (fell_at.present?).
    • Model tests for both.
  3. Loader (lib/world_seed/loader.rb). The floor: keys gain minutes.
    • Refuse anything but a positive integer.
    • Write it in both directions on every load (absent writes nil).
    • Tests: it seeds; it refuses minutes: 0 and minutes: "soon".
  4. Exporter. Emit minutes: under floor: when it is set. Round-trip test.
  5. The Ruby mirror of what a fallen floor hides. This is the step without which engine:rust_gates fails on the new scripts: they hold the goldens to what Ruby reads off the rows.
    • Playthrough#cast_in (playthrough.rb:248) returns [] for a location on a floor this game reached with fell_at set. A location is on a floor when its floors row is the location's own or its parent's, as in playthrough::floor_of.
    • Playthrough#exits (:541) leaves out such locations.
    • foes_in (:336) and Mechanics::State follow from those two.
    • One helper, for example Playthrough#fallen?(location), mirroring Game::fallen exactly.
  6. Protocol and glance.
    • The extension's glance_json (ext/renderedstep/src/lib.rs:1336) adds:
      "floor": glance.floor.as_ref().map(|floor| json!({
          "name": floor.name, "falls_at": floor.falls_at,
          "minutes_left": floor.minutes_left, "countdown": floor.countdown })),
      "collapse": glance.collapse,
    • Playthrough::Glance reads them: a Floor = Data.define(:name, :falls_at, :minutes_left, :countdown), with falls_at as Time.zone.at, like story_time at glance.rb:80. Add collapse, and a line for each in its console to_s (:126).
    • Protocol::V1#glance (protocol/v1.rb:80) adds floor: glance.floor && { name:, falls_at: falls_at.iso8601, minutes_left:, countdown: } and collapse: glance.collapse.
    • docs/protocol/v1/openapi.json, the glance schema (:425ff): add floor (oneOf null or an object, additionalProperties: false, all four required: name string, falls_at string date-time, minutes_left integer ≥ 0, countdown string) and collapse (["string","null"], "The engine's sentence for a floor that gave way under the player; the death statement in ended follows it."). Add both to required and the examples, and bump the minor revision note as earlier additions did.
    • test/contract/protocol_v1_test.rb covers both.
  7. The death-turn notice. When glance.collapse is present, the end notice on the play page (_turn_log.html.erb, the playthrough.over? block) prints it on its own line before Playthrough::DeathNotice's heading and paragraphs. Those stay unchanged. Playthrough::EndNotice is unchanged too: the reason is still :died. Test: a game whose floor gave way under the player shows the sentence, then "You are dead."
  8. Copies of a game. playthrough_floors is already in Playthrough::SqlitePackage's PLAYTHROUGH_TABLES. Check that the package copies fell_at (it does if it copies columns generically). Story::Fork and Story::Snapshot copy no playthroughs.
  9. Doctor finding (report, never repair), with a test: a playthrough_floors row with fell_at whose floor has no minutes. A re-seed that removes minutes: leaves exactly this, and the floor stays fallen in that game.
  10. Sweep.
    • Copy lib/engine_sweep/worlds/a-floor-that-falls.yml, lib/engine_sweep/scripts/waiting-out-a-floor.yml and lib/engine_sweep/scripts/a-floor-falls-behind-you.yml, with their comments and why: notes (below).
    • Vendor this commit's scripts, goldens and records files.
    • Write test/engine_parity/waiting-out-a-floor.checks.json and a-floor-falls-behind-you.checks.json.
    • Update test/ruby_loop_coverage.yml if it must list them.
    • Then rebuild parity/worlds/a-floor-that-falls.sql here from that branch's loader.
  11. Move the pin to this commit.

Out of scope, as the brief says: healing items, safe rooms, rest, the front end (stage 9 renders glance.floor), wardens, and any prompt change.

lib/engine_sweep/worlds/a-floor-that-falls.yml
# A floor that falls. The First Floor is a FLOOR, laid out by the engine the
# first time anybody walks into it, and it says `minutes: 30`: the first time
# a game stands on it, that game's row for it (`playthrough_floors`) starts a
# countdown, and thirty story minutes later the floor gives way in that game.
# A player standing on it then falls with it and dies; a player who has gone
# down already loses only the stair back up, and everybody left on it is dead
# in that game. The world's rows are never touched, so another game finds the
# floor whole and starts a countdown of its own. Wenna Cole is posted to The
# First Floor (`guards:`), so its layout stands her in the room with the stair
# down; she is no foe. The Second Floor has no `minutes:`, so it never falls.
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: A Floor That Falls
  genre: dungeon crawl
  start_time: '2026-10-31T08:00:00Z'
  preface: The floors of this dungeon do not stay up for long.
  summary: Odile goes down before the first floor gives way, or does not.
opening_scene:
  location: The Landing
  characters: [Odile]
  description: You stand on the lantern-lit landing above the first floor.
  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 short blade.
  likes: A clear way back.
  dislikes: Doors she cannot open.
  fears: The floor giving way under her.
  stats: {level: 3, hit_die: 8, strength: 14, dexterity: 12, will: 12}
- fullname: Wenna Cole
  nickname: Wenna
  race: Delvers
  age: 41
  sex: female
  is_protagonist: false
  is_companion: false
  backstory: She sells candles to delvers at the head of the stair down, and has never seen a floor fall.
  personality: Cheerful and talkative.
  appearance: A patched cloak and a sack of candles.
  likes: Company.
  dislikes: Silence.
  fears: Being left in the dark.
  absent: true
  stats: {level: 1, hit_die: 6, strength: 8, dexterity: 10, will: 10}
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.
  teaser: A lantern-lit landing.
  lore: Delvers gather here before they go down.
- name: The First Floor
  detail_level: stub
  kind: warren
  teaser: The first level of the dungeon, cracked through, with a stair somewhere that goes further down.
  floor:
    rooms: 2
    danger: safe
    hazard: none
    down: The Second Floor
    guards: [Wenna Cole]
    minutes: 30
- name: The Second Floor
  detail_level: stub
  kind: warren
  teaser: The second level of the dungeon, where the stair ends.
  floor:
    rooms: 2
    danger: safe
    hazard: none
connections:
- between: [The Landing, The First Floor]
  distance: adjacent
  travel_method: taking stairs
- between: [The First Floor, The Second Floor]
  distance: adjacent
  travel_method: taking stairs
lib/engine_sweep/scripts/waiting-out-a-floor.yml
# WAITING OUT A FLOOR IS FATAL. The First Floor says `minutes: 30`: the first
# time a game stands on it, its countdown starts, and thirty story minutes on
# the floor gives way in that game. Odile walks onto it and waits there. Each
# wait spends five story minutes, and the wait that carries the clock past
# the countdown is not refused: its time is spent, the floor falls, and she
# falls with it. She is dead and the game is over, so the next line is
# refused in the death statement's words.
story: A Floor That Falls
steps:
- id: onto-the-first-floor
  why: >-
    The floor is laid out on this turn, and this game's countdown on it
    starts as it is reached.
  type: "/move The First Floor"
  browser:
    token: into-the-first-floor
    realizes: The First Floor
    replies:
    - purpose: location
      content:
        description: A level of black stone passages, cracked across every few paces.
        lore: The first floor was cut by people nobody remembers.
        place_kind: warren
    - purpose: location
      content:
        description: A low chamber at the foot of the stair, its floor split down the middle.
        lore: Delvers do not rest here long.
        items: []
        people: []
    - purpose: arrival
      unavailable: true
  expect:
    location: The First Floor room 1 (realized)
    dead: false
- id: wait-1
  type: "/wait"
  browser:
    token: wait-1
    replies:
    - purpose: narration
      content: You stand still and listen to the stone settle.
  expect:
    elapsed_minutes: 5.0
    dead: false
- id: wait-2
  type: "/wait"
  browser:
    token: wait-2
    replies:
    - purpose: narration
      content: Grit trickles from a crack overhead.
  expect:
    elapsed_minutes: 5.0
    dead: false
- id: wait-3
  type: "/wait"
  browser:
    token: wait-3
    replies:
    - purpose: narration
      content: The crack in the floor is wider than it was.
  expect:
    elapsed_minutes: 5.0
    dead: false
- id: wait-4
  type: "/wait"
  browser:
    token: wait-4
    replies:
    - purpose: narration
      content: Somewhere further in, something heavy drops.
  expect:
    elapsed_minutes: 5.0
    dead: false
- id: wait-5
  type: "/wait"
  browser:
    token: wait-5
    replies:
    - purpose: narration
      content: The floor shudders under your boots.
  expect:
    elapsed_minutes: 5.0
    dead: false
- id: wait-6
  type: "/wait"
  browser:
    token: wait-6
    replies:
    - purpose: narration
      content: The floor tilts.
  expect:
    elapsed_minutes: 5.0
    dead: true
- id: nothing-after
  type: "/wait"
  expect:
    refused: true
    dead: true
    offers: is dead, and this playthrough is over
lib/engine_sweep/scripts/a-floor-falls-behind-you.yml
# A FLOOR FALLS BEHIND YOU, IN YOUR GAME ONLY. The First Floor says
# `minutes: 30`. Odile walks onto it, passes Wenna Cole at the head of the
# stair down, and goes on down to The Second Floor, which has no countdown.
# She waits there until the first floor's thirty minutes have run out. It
# falls in her game: she is not on it, so she lives, but the stair back up is
# no longer a way out, and naming it, or the floor, is refused in the
# engine's words. Wenna is dead in this game (`cast_in` leaves out everybody
# on a fallen floor), which the sweep cannot stand beside her to see. The
# world's rows are untouched, so a second game walks onto The First Floor
# whole and finds Wenna standing at the stair.
story: A Floor That Falls
steps:
- id: onto-the-first-floor
  why: >-
    The floor is laid out on this turn, and Wenna Cole is stood in the room
    with the stair down. This game's countdown on it starts as it is
    reached.
  type: "/move The First Floor"
  browser:
    token: into-the-first-floor
    realizes: The First Floor
    replies:
    - purpose: location
      content:
        description: A level of black stone passages, cracked across every few paces.
        lore: The first floor was cut by people nobody remembers.
        place_kind: warren
    - purpose: location
      content:
        description: A low chamber at the foot of the stair, its floor split down the middle.
        lore: Delvers do not rest here long.
        items: []
        people: []
    - purpose: arrival
      unavailable: true
  expect:
    location: The First Floor room 1 (realized)
    dead: false
- id: to-the-way-down
  type: "/move The First Floor room 2"
  browser:
    token: first-floor-room-2
    realizes: The First 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 First Floor room 2 (realized)
    present:
    - Wenna Cole
    exits_include:
    - The Second Floor (stub)
- id: down-onto-the-second-floor
  why: >-
    The Second Floor has no countdown of its own.
  type: "/move The Second Floor"
  browser:
    token: into-the-second-floor
    realizes: The Second Floor
    replies:
    - purpose: location
      content:
        description: A colder level, the air still and the dark close.
        lore: Few delvers come this far.
        place_kind: warren
    - purpose: location
      content:
        description: The foot of the stair, cold and quiet.
        lore: The stair comes down here from the floor above.
        items: []
        people: []
    - purpose: arrival
      unavailable: true
  expect:
    location: The Second Floor room 1 (realized)
    exits_include:
    - The First Floor room 2 (realized)
- id: wait-1
  type: "/wait"
  browser:
    token: wait-1
    replies:
    - purpose: narration
      content: You stand at the foot of the stair and listen.
  expect:
    elapsed_minutes: 5.0
    exits_include:
    - The First Floor room 2 (realized)
- id: wait-2
  type: "/wait"
  browser:
    token: wait-2
    replies:
    - purpose: narration
      content: Grit trickles down the stair from above.
  expect:
    elapsed_minutes: 5.0
- id: wait-3
  type: "/wait"
  browser:
    token: wait-3
    replies:
    - purpose: narration
      content: Something above you groans.
  expect:
    elapsed_minutes: 5.0
- id: wait-4
  type: "/wait"
  browser:
    token: wait-4
    replies:
    - purpose: narration
      content: Dust drifts down the stair.
  expect:
    elapsed_minutes: 5.0
- id: wait-5
  type: "/wait"
  browser:
    token: wait-5
    replies:
    - purpose: narration
      content: The groaning above grows louder.
  expect:
    elapsed_minutes: 5.0
    exits_include:
    - The First Floor room 2 (realized)
- id: the-first-floor-falls-above-her
  why: >-
    This wait carries the clock past the first floor's thirty minutes. It
    falls in this game: Odile, below it, lives, and the stair up is no
    longer a way out.
  type: "/wait"
  browser:
    token: wait-6
    replies:
    - purpose: narration
      content: A roar comes down the stair, and then nothing.
  expect:
    elapsed_minutes: 5.0
    dead: false
    exits_exclude:
    - The First Floor room 2
- id: there-is-no-way-back-up
  why: >-
    Naming the fallen floor's room is refused in the engine's own words.
  type: "/move The First Floor room 2"
  expect:
    refused: true
    offers: The First Floor has fallen. There is no way back onto it.
    location: The Second Floor room 1 (realized)
- id: nor-onto-the-floor-by-its-name
  type: "/move The First Floor"
  expect:
    refused: true
    offers: The First Floor has fallen. There is no way back onto it.
- id: a-second-game-finds-the-first-floor-whole
  why: >-
    The floor fell in Odile's first game only. Another game walks onto it
    from the landing, whole.
  player: second
  type: "/move The First Floor"
  expect:
    location: The First Floor room 1 (realized)
    dead: false
- id: and-wenna-is-alive-at-its-stair
  why: >-
    Wenna stands at the stair down in this game, and the stair leads on.
  player: second
  type: "/move The First Floor room 2"
  expect:
    location: The First Floor room 2 (realized)
    present:
    - Wenna Cole
    exits_include:
    - The Second Floor room 1 (realized)

…r on it with it

A floor whose row has minutes falls in a game that many story minutes
after the game first stood on it (playthrough_floors.arrived_at). The
engine asks before a line is read and again once the line's time is
spent: every reached floor whose deadline has passed gets fell_at (the
deadline), and a player standing on one is harmed to zero and the game
ends. A wait across the deadline is played, never refused, and is fatal.

A fallen floor is gone in that game only: cast_in holds nobody on it,
Game::exits and the room's exits leave out every way onto it, and a move
that names such a way, or the floor, is refused in the engine's words:
"{floor} has fallen. There is no way back onto it." On the live path no
reader is asked. The world's rows are untouched, so another game finds
the floor whole and runs its own countdown.

The glance gains floor (name, falls_at, minutes_left and the countdown
line "{floor} falls in {h} hours {m} minutes." / "{floor} falls in {m}
minutes.") and collapse ("{floor} gave way at {time}, and {fullname} fell
with it.", which the unchanged death statement follows). The engine-alone
report notes the collapse. No request carries any of it.

Migration 20261031120000 adds floors.minutes and playthrough_floors.fell_at,
applied to every world dump as Rails' SQLite adapter applies it.
store::OPTIONAL_COLUMNS holds both, so the game at its current pin still
opens and plays; SCHEMA_VERSION and shape.txt do not move. No golden or
records file moved, and the fight simulator's reference fights are
unchanged.

New world a-floor-that-falls (the game's loader at server 132b475, then
the migration and minutes set) and scripts waiting-out-a-floor and
a-floor-falls-behind-you, in PASSING with goldens and records.
…ver 383a67d

The game's pin PR (renderedstep/server#374) reads minutes:, so the dump is
now the one its own loader writes from a schema load: minutes is written by
the loader rather than by hand. The rows are the same; only timestamps, the
schema digest and the order the dump lists them in moved. No golden or
records file moves.
@calebl
calebl merged commit 8b89bca into main Oct 30, 2026
4 checks passed
calebl added a commit to renderedstep/server that referenced this pull request Oct 30, 2026
calebl added a commit to renderedstep/server that referenced this pull request Oct 30, 2026
Pin renderedstep/engine#98's head (064084f): let a floor fall in a game when its countdown runs out, and the player on it with it
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