Repository navigation
Let a floor fall in a game when its countdown runs out, and the player on it with it - #98
Merged
Merged
Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stage 6 of the dungeon plan, engine half: the countdown and the collapse (D3 b, D9 a).
minutesfalls in a game that number of story minutes after that game first stood on it. The deadline isplaythrough_floors.arrived_at + floors.minutes.A world without
minutesplays byte for byte as before. No model was called; every command ran withOPENROUTER_API_KEYandTYPESAFE_API_KEYunset.The words
All three are the plan's §4.3 drafts. The only change is that a unit of 1 is singular.
Countdown (
Countdown::in_words, on the glance asfloor.countdown):hour/minuteare 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.Collapse (
Game::collapse_to_s, on the glance ascollapse; the death statement follows it unchanged):{time}is the story time the floor fell, asstrftime("%-l:%M %P")in UTC (moment::clock_time), for example:The First Floor gave way at 8:30 am, and Odile fell with it.A fallen floor named (
refusal::fallen):For example:
The First Floor has fallen. There is no way back onto it.A refusal on the live path carries the usualNothing has changed.after it, as every unplayable refusal does.Where each shows:
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.the game: {collapse}, on the line that took the player.tests/floor_falls.rschecks every request of a walk past the deadline. The two new records files hold the requests themselves, and none of them containsfalls in,gave wayorhas fallen.What changed
turn.rsMechanics::floors_fall. Every floor this game reached whose deadline is at or before story time now (and has nofell_atyet) getsfell_at= its deadline.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.playthrough_floors.fell_at.catch_up_world. On the told path it is a new journal step,floor_fell, afterworld_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.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 itsstory_timestampis 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()andGame::collapse_to_s(), plus theCountdownstruct and its words.cast_inreturns nobody for a room on a fallen floor, sofoes_in,followers, the room's cast and the glance's people follow.exitsleaves out rooms on a fallen floor, so the dump'sexitsand the narrator's "Ways out of here" line follow.turn.rswhole_room_of. The room'sexits(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 newRoom::fallenlist instead (room::Fallen { place, floor }), which is only ever named to be refused.grammar.rs.read_moverefuses with the fallen sentence when the name finds no way out but lands onRoom::fallen.Grammar::fallen_named(line)does the same for a whole line.told.rsread_lineturns that grammar refusal into adeclinedmove intent, so the live path refuses it without asking a reader.Mechanics::refusal_foralso 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) andGlance::collapse.moment.rs.clock_time(at),time_of_dayto the minute.refusal.rs.fallen(floor).store.rs.floors.minutesandplaythrough_floors.fell_atjoinOPTIONAL_COLUMNS, so a database with or without them opens.Store::has_column(table, column)is new.SCHEMA_VERSIONandshape.txtdo not move. The game at its current pin opens and plays as before; nothing there falls.AddMinutesToFloorsAndFellAtToPlaythroughFloors), applied to all 34 world dumps inparity/worlds/. I used the same method as stage 5:bin/rails db:migrateran on a copy of it in a scratch checkout of the game atSERVER_COMMIT(132b475), holding only this migration.20261029154235had its version row added before the run and deleted after."minutes" integeronfloors(a trailingNULLon each floor row),"fell_at" datetime(6)onplaythrough_floors, and the version row.a69297a).playthroughandglancerows of the module table.Things a reviewer should know
arrived_at. Stage 5 writesarrived_atas 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.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_atis the deadline, not the minute the engine noticed it.ended_atis the minute it noticed (8:31:30 in the sweep).What did not move
parity --check parity: AGREED 108 of 112. The other four are runner-only, as before.two-floors-and-a-key,a-guarded-stair,a-deeper-floor,three-floors-that-raise-you) have nominutes, so nothing in them falls.playthrough_floorsis not inparity::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-fallsand two scripts, both inPASSINGwith 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), withoutminutes:, which that loader does not read. Then the migration ran, andUPDATE floors SET minutes = 30on 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 readsminutes:.The world. Odile is level 3 on a d8 (18 hit points). Under the landing are two floors of two rooms each:
minutes: 30, and Wenna Cole (no foe) is posted to it, so its layout stands her in the room with the stair down.waiting-out-a-floor(8 steps):dead: true.Odile is dead, and this playthrough is over. …a-floor-falls-behind-you(13 steps):present: [Wenna Cole].The First Floor room 2 (realized).dead: false, andexits_exclude: [The First Floor room 2]./move The First Floor room 2and/move The First Floorare both refused:The First Floor has fallen. There is no way back onto it.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.rsasserts it throughGame::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):flooris{The First Floor, 8:30, 29, "The First Floor falls in 29 minutes."}. After 5 waits it reads… falls in 4 minutes.fell_atis 8:30, hp is 0, the game is over,flooris gone,collapsereadsThe First Floor gave way at 8:30 am, and Odile fell with it., andendedis the death statement.fell_atis set, hp stays 18,exitsis onlyThe 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.Game::cast_inof her room is empty for the first game, andGame::fallenholds.fell_atand its own countdown.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.minutesnever falls, however long ago it was reached.clock_timeat 8:30 am, 1:05 pm and 12:00 am.tests/engine.rs): a database from before the migration opens and plays. Afloors.minutesdeclared asvarchar, or afell_atdeclaredNOT NULL, is refused.every_world_has_the_shape_the_engine_is_written_againstholds 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.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_COMMITdoes not move. The game's extension builds against this checkout unchanged:Glanceonly gains fields, and the extension reads the ones it knows by name.What the server pin PR must change
Line numbers are at
132b475.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_COLUMNSholds both facts.db/schema.rbmoves to2026_10_31_120000.Floorvalidatesminutesas an integer greater than 0, allowing nil.Playthrough::Floorgains#falls_at(arrived_at + location.floor.minutes.minutes, nil without minutes) and#fallen?(fell_at.present?).lib/world_seed/loader.rb). Thefloor:keys gainminutes.minutes: 0andminutes: "soon".minutes:underfloor:when it is set. Round-trip test.engine:rust_gatesfails 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 withfell_atset. A location is on a floor when itsfloorsrow is the location's own or its parent's, as inplaythrough::floor_of.Playthrough#exits(:541) leaves out such locations.foes_in(:336) andMechanics::Statefollow from those two.Playthrough#fallen?(location), mirroringGame::fallenexactly.glance_json(ext/renderedstep/src/lib.rs:1336) adds:Playthrough::Glancereads them: aFloor = Data.define(:name, :falls_at, :minutes_left, :countdown), withfalls_atasTime.zone.at, likestory_timeatglance.rb:80. Addcollapse, and a line for each in its consoleto_s(:126).Protocol::V1#glance(protocol/v1.rb:80) addsfloor: glance.floor && { name:, falls_at: falls_at.iso8601, minutes_left:, countdown: }andcollapse: glance.collapse.docs/protocol/v1/openapi.json, the glance schema (:425ff): addfloor(oneOfnull or an object,additionalProperties: false, all four required:namestring,falls_atstringdate-time,minutes_leftinteger ≥ 0,countdownstring) andcollapse(["string","null"], "The engine's sentence for a floor that gave way under the player; the death statement inendedfollows it."). Add both torequiredand the examples, and bump the minor revision note as earlier additions did.test/contract/protocol_v1_test.rbcovers both.glance.collapseis present, the end notice on the play page (_turn_log.html.erb, theplaythrough.over?block) prints it on its own line beforePlaythrough::DeathNotice's heading and paragraphs. Those stay unchanged.Playthrough::EndNoticeis unchanged too: the reason is still:died. Test: a game whose floor gave way under the player shows the sentence, then "You are dead."playthrough_floorsis already inPlaythrough::SqlitePackage'sPLAYTHROUGH_TABLES. Check that the package copiesfell_at(it does if it copies columns generically).Story::ForkandStory::Snapshotcopy no playthroughs.playthrough_floorsrow withfell_atwhose floor has nominutes. A re-seed that removesminutes:leaves exactly this, and the floor stays fallen in that game.lib/engine_sweep/worlds/a-floor-that-falls.yml,lib/engine_sweep/scripts/waiting-out-a-floor.ymlandlib/engine_sweep/scripts/a-floor-falls-behind-you.yml, with their comments andwhy:notes (below).test/engine_parity/waiting-out-a-floor.checks.jsonanda-floor-falls-behind-you.checks.json.test/ruby_loop_coverage.ymlif it must list them.parity/worlds/a-floor-that-falls.sqlhere from that branch's loader.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
lib/engine_sweep/scripts/waiting-out-a-floor.yml
lib/engine_sweep/scripts/a-floor-falls-behind-you.yml