Repository navigation
Stand a floor's guards on its way down, and birth its people at its level (dungeon stage 3, engine) - #95
Merged
Conversation
…evel A person the world posts to a floor (characters.posted_to_id) and keeps absent is stood in the floor's way-down room when the floor is laid out, placed by spot::place as an admitted person is, with deliberately_absent cleared and the posting kept. On a floor with no way down the room is rolled from every room but the entry, as the last draw of the place's own generator, so a guarded floor's rooms, doorways and way down are the unguarded floor's. It is part of laying the floor out: once, for the world. No later game, and no later absence the file writes, stands a guard again; somebody posted who already stands somewhere is left there. Everybody the engine births on a floor, or in a room of one, takes the floor's floors.monster_level; the hit die and abilities are rolled exactly as before, and with no level named the starting level is unchanged. Migration 20261030230000 adds floors.monster_level and characters.posted_to_id (a reference to locations, on delete nullify). Every world dump carries it as Rails' SQLite adapter applied it, which rebuilds characters. store::OPTIONAL_COLUMNS holds the column, key and index facts, so the game at its current pin still opens and plays; SCHEMA_VERSION and shape.txt do not move. Four records files gain only "posted_to_id": null on the character rows they write; no golden moved, and the fight simulator's reference fights are unchanged. New worlds a-guarded-stair and a-deeper-floor (the game's loader at server 5678109, then the migration and the new rows) and scripts a-guard-waits-at-the-stairs and a-deeper-floor-births-stronger-people, in PASSING with goldens and records.
…renderedstep/server 6b85c8f The game's pin PR (renderedstep/server#370) reads guards: and monster_level:, so both dumps are now the ones its own loader writes from a schema load: posted_to_id and monster_level are written by the loader rather than by hand. The rows are the same; only timestamps and the order the characters table and its constraints are dumped in moved. No golden or records file moves. The test that rebuilds characters from before the migration now cuts out only the posting's key, wherever it is listed.
…tep/server 6b85c8f)
calebl
added a commit
to renderedstep/server
that referenced
this pull request
Oct 30, 2026
98cec5f adds to the head pinned before only the engine's CI, its own tests, and parity/worlds/a-guarded-stair.sql and a-deeper-floor.sql rebuilt from this branch's loader (6b85c8f). No golden, script or vector portion changed: bin/rails engine:vendored passes, engine:rust_gates passes all 108 scripts.
calebl
added a commit
to renderedstep/server
that referenced
this pull request
Oct 30, 2026
Pin renderedstep/engine#95's head (6247b4b): stand a floor's guards on its way down, and birth its people at its level
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 3 of the dungeon plan, engine half: who stands on a floor.
characters.posted_to_id) and keeps absent is stood in the floor's way-down room when the floor is laid out. This happens once, for the world, as part of laying the floor out. No later game, and no later absence the file writes, moves them again.monster_level. Their hit die and abilities are rolled exactly as before.A world with neither key plays byte for byte as before. No prompt template changed. No model was called.
What changed
floor.rs.FloorgainsguardedandLaidgainsguard_room.realize.rslay_out. For a floor, it reads who is posted there and still stands nowhere (posted_to_id= the floor,location_idnull), in id order. Afteropen_the_way_in, each of them is written into the guard room:location_id= the room;deliberately_absent= false;x/yfromspot::place, as an admitted person is placed.posted_to_idis kept, so the doctor can tell a posted guard apart. Somebody posted who already stands somewhere is left there. Layout still runs only for a place with no rooms, so a later game, or a later re-seed that writes the guard absent again, never stands them a second time.realize.rsadmit_new. A person born at a location that is a floor, or a room whose parent is a floor, getslevel= that floor'smonster_levelwhen its row names one. Otherwise they getSTARTING_LEVEL(1), as before. The block is otherwisestat_block::rollunchanged: the same seed, the same hit die and the same three abilities. People named by the room writer who already exist (admit_known) keep their own sheet.Store.
store::OPTIONAL_COLUMNSgains four facts, soshapeleaves them out and a database opens with or without them:floors.monster_level;characters.posted_to_id;on delete SET NULL);Either column declared any other way is still a difference.
SCHEMA_VERSIONandshape.txtdo not move, so the game at its current pin still opens and plays: both columns read as null, which means nobody posted and nobody born stronger.Migration 20261030230000 (
AddGuardsAndMonsterLevelToFloors) is applied to every world dump inparity/worlds/.bin/rails db:migrateran on a copy of each world, in a scratch checkout of the game atSERVER_COMMITholding only this migration.characters. The SQLite adapter adds a foreign key by rebuilding the table. So in each dump thecharacterstable and its indexes move to the end, the foreign keys are listed in Rails' order, and each row gains a trailingNULL.refused_lines, which the engine treats as optional). The migration run did not create it: its version row was added before the run and deleted after.two-floors-and-a-keyalready had that migration; its migrated dump was byte-identical to the reference run.Records. Four existing records files gain only
"posted_to_id": nullon the character rows they write, because the column is now in every world:a-deadline-places-what-the-arc-waits-for;a-room-written-while-the-arc-waits;a-thirteenth-person-is-born;unfinished-realization-resumes-on-entry.No other key moved, and no existing golden changed.
README. The
floorrow of the module table mentions the guard room.Sweep
Two new worlds and two new scripts, both scripts in
PASSINGwith goldens and records files. Both worlds are the game's loader atSERVER_COMMIT(5678109) loading the world files below without the keys it cannot read yet. Then the migration ran, and the rows the new loader should write were added by SQL:a-guarded-stair:characters.posted_to_id= The Upper Floor for Brask the Stairwarden;a-deeper-floor:floors.monster_level = 4for The Bottom Floor.As with
two-floors-and-a-key, rebuild both dumps from the pin branch's own loader once it readsguards:andmonster_level:.a-guard-waits-at-the-stairs(9 steps). The world: Brask the Stairwarden is posted to The Upper Floor (3 rooms, way down to The Lower Floor),absent: true, hostile, level 1 on a d6, and carries the stair key. The stair down is keyed with it./attack Brask the Stairwardenkills him in one blow, and the stair key falls where he stood.offers: locked), the key opens it, and the game goes down to The Lower Floor's entry.The records show the layout turn writing his row once:
location_id= room 2 (1000000005),deliberately_absent0,x/y9/3,posted_to_idkept.a-deeper-floor-births-stronger-people(3 steps). The world: The Bottom Floor (monster_level: 4) and The Upper Floor (none), each one room off a landing. Their names were chosen so each room's population draws exactly one person.The sweep has no expectation key for a level, so the scripts assert
hp_of(a newborn's HP is their maximum). The records pinlevel: 4itself.I checked every
expect:in both scripts against the engine's dumps: 33 expectations, 0 mismatches.Tests
floor::tests.guards_stand_on_the_way_down_and_move_no_other_draw, over 40 places with and without a way down: a guarded floor's layout and way down equal the unguarded floor's, and its guard room is the way down or a non-entry room. A one-room floor's guard stands in its only room.tests/floor_people.rs(new; live path with a scripted model):absent:would), a third game walks in, and the first walks to room 2: he stays nowhere.monster_levelnulled, the same birth is level 1 with the same hit die and abilities. The Upper Floor gives level 1, or 7 when its row is set to 7.tests/engine.rs).charactersis rebuilt withoutposted_to_id, its key and its index, as Rails rebuilds a table, andfloors.monster_levelis dropped.floors.monster_leveldeclaredvarcharis refused (found column floors.monster_level varchar pk=0), and so is a new index oncharacters (posted_to_id, id).Local runs, all with both provider keys unset:
cargo fmt --check,cargo clippy --workspace --all-targets --locked -- -D warningsandcargo test --workspace --locked: all pass.parity --check parity: 104 of 108 agree. The other four are runner-only, as before.fight --check sim: AGREED: 10 fight goldens, unchanged. No rule they cover moved: the level change applies only to people born on a floor whose row names amonster_level, and no reference fight is on a floor.SERVER_COMMIT(5678109), on its own schema (no new columns), with the extension built from this checkout.parity/runner.sh: 106 of 108 agree; the two new scripts are "not in the checkout yet".engine:rust_gates: PASSED for 106 scripts (goldens, invariants, doctor and audit).SERVER_COMMITdoes not move. At its current pin (5678109) the game has neither column, and the engine opens it and plays as before.What the server pin PR must change
db/migrate/20261030230000_add_guards_and_monster_level_to_floors.rb, with exactly this version and declaration, so the dumps here stay byte-true.store::OPTIONAL_COLUMNSholds every fact of it.db/schema.rbmoves to2026_10_30_230000. No later migration was on main (7d6324c) when this was written.Character belongs_to :posted_to, class_name: "Location", optional: true.Location has_many :posted_guards, class_name: "Character", foreign_key: :posted_to_id, dependent: :nullify(any name).Floorvalidatesmonster_levelinCharacter::LEVELS(1..20), allowing nil.lib/world_seed/loader.rb). Thefloor:keys gainmonster_levelandguards.monster_level, or one outsideCharacter::LEVELS;guardsthat is not a list of names;absent: true, or with alocation;statsblock;floors.monster_levelin both directions on every load (an absent key writes nil);characters.posted_to_idfor every guard, and nil for every other character of the file.load_characters!writeslocationanddeliberately_absentin both directions on every load today. For a posted character whose floor is already laid out (it has rooms), it must leavelocation_id,deliberately_absent,xandyas they are: the layout stood the guard there, once, for the world. The engine never stands a guard a second time, so re-asserting absence would leave them nowhere for good. Before layout, the file'sabsent: trueis written as today.monster_leveland a guard; refuses each case above; re-seed after layout leaves a placed guard where they stand.floor: { …, monster_level, guards }(guards by full name, in id order) and keepabsent: trueandstatson each guard. Round-trip test.Character::Registrymirror (plan X4). Where the registry admits a newly written person, the level is themonster_levelof the room's floor (the location itself or its parent) when set, elseStatBlock's starting level. The hit die and abilities are rolled as before. This mirrorsrealize.rsadmit_new.floorsrow;location_idnil) after their floor is laid out (it has rooms). A re-seed from before the re-seed rule can leave one so.stat_blocks_unmoved, but at this commit that invariant holds only the five stat columns, and a guard's stats never change. What a posted guard's layout move touches iscast_unmoved(location_id,x,y). It must accept a character withposted_to_idwhose floor is laid out and who stands in a room of that floor at the spotLocation::Placement.rolled_in_the_world?gives, even though the file says nowhere. This is the one move at layout, asgeometry_unmovedaccepts a box it sizes (laid_out_alone?).stat_blocks_unmovedneeds no change for guards. People born on a floor at its monster level are engine-born and covered however engine-born people are covered today.Story::Fork,Story::SnapshotandPlaythrough::SqlitePackagecopycharacters.posted_to_id, remapped to the copied location.lib/engine_sweep/worlds/a-guarded-stair.ymlanda-deeper-floor.yml, and the scriptslib/engine_sweep/scripts/a-guard-waits-at-the-stairs.ymlanda-deeper-floor-births-stronger-people.yml, with their comments andwhy:notes (below).test/engine_parity/<script>.checks.jsonfor both.posted_to_id: null.test/ruby_loop_coverage.ymlif it lists scripts.parity/worlds/a-guarded-stair.sqlanda-deeper-floor.sqlhere from that branch's loader.Out of scope, as the brief says: wardens, bane, lure, the dead staying dead, escalation, the dungeon world file, the other stages, and any prompt change.
lib/engine_sweep/worlds/a-guarded-stair.yml
lib/engine_sweep/worlds/a-deeper-floor.yml
lib/engine_sweep/scripts/a-guard-waits-at-the-stairs.yml
lib/engine_sweep/scripts/a-deeper-floor-births-stronger-people.yml