Skip to content

Stand a floor's guards on its way down, and birth its people at its level (dungeon stage 3, engine) - #95

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

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

Conversation

@calebl

@calebl calebl commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Stage 3 of the dungeon plan, engine half: who stands on a floor.

  • Guards. 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. 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. Everybody the engine births on a floor, or in a room of one, takes the floor's 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. Floor gains guarded and Laid gains guard_room.

    • On a floor with a way down, the guard room is the way-down room.
    • On a floor with no way down, it is rolled from every room but the entry (the entry only when it is the only room). This roll is the last draw of the place's own generator, after the extra doors and the sorts.
    • So a guarded floor's rooms, doorways and way down are the unguarded floor's, draw for draw. A floor nobody is posted to rolls nothing new.
  • realize.rs lay_out. For a floor, it reads who is posted there and still stands nowhere (posted_to_id = the floor, location_id null), in id order. After open_the_way_in, each of them is written into the guard room:

    • location_id = the room;
    • deliberately_absent = false;
    • x/y from spot::place, as an admitted person is placed.

    posted_to_id is 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.rs admit_new. A person born at a location that is a floor, or a room whose parent is a floor, gets level = that floor's monster_level when its row names one. Otherwise they get STARTING_LEVEL (1), as before. The block is otherwise stat_block::roll unchanged: 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_COLUMNS gains four facts, so shape leaves them out and a database opens with or without them:

    • floors.monster_level;
    • characters.posted_to_id;
    • its foreign key (on delete SET NULL);
    • its index.

    Either column declared any other way is still a difference. SCHEMA_VERSION and shape.txt do 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 in parity/worlds/.

    • How it was applied. Each dump re-dumped byte-identically first. Then bin/rails db:migrate ran on a copy of each world, in a scratch checkout of the game at SERVER_COMMIT holding only this migration.
    • Rails rebuilds characters. The SQLite adapter adds a foreign key by rebuilding the table. So in each dump the characters table and its indexes move to the end, the foreign keys are listed in Rails' order, and each row gains a trailing NULL.
    • Most dumps lacked 20261029154235 (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.
    • Byte check. two-floors-and-a-key already had that migration; its migrated dump was byte-identical to the reference run.
  • Records. Four existing records files gain only "posted_to_id": null on 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 floor row of the module table mentions the guard room.

Sweep

Two new worlds and two new scripts, both scripts in PASSING with goldens and records files. Both worlds are the game's loader at SERVER_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 = 4 for The Bottom Floor.

As with two-floors-and-a-key, rebuild both dumps from the pin branch's own loader once it reads guards: and monster_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.

  1. Walking in lays the floor out. The entry room is empty.
  2. Room 2 is the way-down room (its exits include The Lower Floor). Brask stands there, present and a foe, at 6 HP. The room writer wrote nobody.
  3. /attack Brask the Stairwarden kills him in one blow, and the stair key falls where he stood.
  4. Take the key. The stair down is still locked (offers: locked), the key opens it, and the game goes down to The Lower Floor's entry.
  5. A second game walks to room 2 with no call and finds Brask there, alive in that game, at 6 HP.

The records show the layout turn writing his row once: location_id = room 2 (1000000005), deliberately_absent 0, x/y 9/3, posted_to_id kept.

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 room writer writes one person into each floor's room.
  • Hollin Graves, born on the bottom floor, stands at 18 HP: a d6 at level 4.
  • Wenna Cole, born on the upper floor, stands at 8 HP: a d8 at level 1.

The sweep has no expectation key for a level, so the scripts assert hp_of (a newborn's HP is their maximum). The records pin level: 4 itself.

I checked every expect: in both scripts against the engine's dumps: 33 expectations, 0 mismatches.

Tests

  • floor::tests.
    • The every-count test now posts guards on every other place and checks the guard room: inside the floor, never the entry unless it is the only room, and the way-down room whenever there is one.
    • New 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):
    • Brask is absent and posted before layout. After it he stands in the way-down room, which is not the entry, no longer absent and still posted, at a spot inside that room's box.
    • Generated once. A second game walks onto the floor and his row is unchanged. Then he is written absent again (as a re-seed that re-asserted absent: would), a third game walks in, and the first walks to room 2: he stays nowhere.
    • A posted guard who already stands somewhere (the landing) is left there.
    • On a floor with no way down, he stands in a room other than the entry.
    • Somebody born on The Bottom Floor is level 4. With monster_level nulled, 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.
  • Store (tests/engine.rs).
    • A database from before the migration opens and plays. To build one, characters is rebuilt without posted_to_id, its key and its index, as Rails rebuilds a table, and floors.monster_level is dropped.
    • A floors.monster_level declared varchar is refused (found column floors.monster_level varchar pk=0), and so is a new index on characters (posted_to_id, id).

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: 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 a monster_level, and no reference fight is on a floor.
  • I also ran the CI runner job locally against the game at 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_COMMIT does 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

  1. Migration 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_COLUMNS holds every fact of it. db/schema.rb moves to 2026_10_30_230000. No later migration was on main (7d6324c) when this was written.
    class AddGuardsAndMonsterLevelToFloors < ActiveRecord::Migration[8.1]
      def change
        add_column :floors, :monster_level, :integer
        add_reference :characters, :posted_to, foreign_key: { to_table: :locations, on_delete: :nullify }
      end
    end
  2. Models.
    • 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).
    • Floor validates monster_level in Character::LEVELS (1..20), allowing nil.
    • Model tests, and the dice-free factories gain the attributes.
  3. Loader (lib/world_seed/loader.rb). The floor: keys gain monster_level and guards.
    • Refuse:
      • a non-Integer monster_level, or one outside Character::LEVELS;
      • a guards that is not a list of names;
      • a name that is not a character of this file;
      • a guard without absent: true, or with a location;
      • a guard without a whole stats block;
      • the protagonist or a companion as a guard;
      • one character posted to two floors.
    • Write:
      • floors.monster_level in both directions on every load (an absent key writes nil);
      • characters.posted_to_id for every guard, and nil for every other character of the file.
    • Re-seed rule. load_characters! writes location and deliberately_absent in both directions on every load today. For a posted character whose floor is already laid out (it has rooms), it must leave location_id, deliberately_absent, x and y as 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's absent: true is written as today.
    • Tests: seeds a floor with monster_level and a guard; refuses each case above; re-seed after layout leaves a placed guard where they stand.
  4. Exporter. Emit floor: { …, monster_level, guards } (guards by full name, in id order) and keep absent: true and stats on each guard. Round-trip test.
  5. Ruby Character::Registry mirror (plan X4). Where the registry admits a newly written person, the level is the monster_level of the room's floor (the location itself or its parent) when set, else StatBlock's starting level. The hit die and abilities are rolled as before. This mirrors realize.rs admit_new.
  6. Doctor findings (report, never repair), each with a test:
    • a character posted to a location that has no floors row;
    • a posted character still nowhere (location_id nil) after their floor is laid out (it has rooms). A re-seed from before the re-seed rule can leave one so.
  7. Invariants. The plan names 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 is cast_unmoved (location_id, x, y). It must accept a character with posted_to_id whose floor is laid out and who stands in a room of that floor at the spot Location::Placement.rolled_in_the_world? gives, even though the file says nowhere. This is the one move at layout, as geometry_unmoved accepts a box it sizes (laid_out_alone?). stat_blocks_unmoved needs 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.
  8. Copies of a world: Story::Fork, Story::Snapshot and Playthrough::SqlitePackage copy characters.posted_to_id, remapped to the copied location.
  9. Sweep.
    • Copy lib/engine_sweep/worlds/a-guarded-stair.yml and a-deeper-floor.yml, and the scripts lib/engine_sweep/scripts/a-guard-waits-at-the-stairs.yml and a-deeper-floor-births-stronger-people.yml, with their comments and why: notes (below).
    • Vendor this commit's goldens, scripts and records, and write test/engine_parity/<script>.checks.json for both.
    • Vendor the four records files that gained posted_to_id: null.
    • Update test/ruby_loop_coverage.yml if it lists scripts.
    • Then rebuild parity/worlds/a-guarded-stair.sql and a-deeper-floor.sql here from that branch's loader.
  10. Move the pin to this commit.

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
# A floor with a guard on its way down. The Upper Floor and The Lower Floor
# are FLOORS, laid out by the engine the first time anybody walks into them.
# Brask the Stairwarden is posted to The Upper Floor (`guards:`): the file
# keeps him absent (`absent: true`), and when the floor is laid out the
# engine stands him in the room it rolled for the way down, once, for the
# world. He carries the stair key, and the stair down is locked with it: kill
# him, take the key, open the stair, go down.
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, and the things that keep the stairs.
  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.
  - name: Stairwardens
    description: Grey, silent things that keep the dungeon's stairs.
    monstrous: true
story:
  title: A Guarded Stair
  genre: dungeon crawl
  start_time: '2026-10-30T08:00:00Z'
  preface: Every stair down in the dungeon has something standing at the top of it.
  summary: Odile goes down past the thing that keeps the stair.
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 dark below the first floor.
  stats: {level: 3, hit_die: 8, strength: 14, dexterity: 12, will: 12}
- fullname: Brask the Stairwarden
  nickname: Brask
  race: Stairwardens
  age: 90
  sex: male
  is_protagonist: false
  is_companion: false
  backstory: He has kept the stair down for longer than the delvers have come.
  personality: Silent and patient.
  appearance: A grey, stooped thing with a long iron key on a cord.
  likes: Stillness.
  dislikes: Footsteps on the stair.
  fears: Nothing.
  hostile: true
  absent: true
  stats: {level: 1, hit_die: 6, strength: 8, dexterity: 8, will: 8}
  items:
  - name: stair key
    description: A long iron key on a cord, cold to hold.
    properties: '{}'
    use_kind: key
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 Upper Floor
  detail_level: stub
  kind: warren
  teaser: The first level of the dungeon, with a stair somewhere that goes further down.
  floor:
    rooms: 3
    danger: uneasy
    hazard: none
    down: The Lower Floor
    guards: [Brask the Stairwarden]
- 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
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
lib/engine_sweep/worlds/a-deeper-floor.yml
# Two floors off one landing, an upper one and a bottom one. Both are FLOORS of
# one room, laid out by the engine the first time anybody walks in. The Bottom
# Floor's `monster_level: 4` is the level of everybody the engine births on it:
# the person its room writer writes there is born at level 4, their hit die and
# abilities still rolled as anybody's are. The Upper Floor names no level, so
# the person born there is born at the ordinary level 1.
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 Deeper Floor
  genre: dungeon crawl
  start_time: '2026-10-30T08:00:00Z'
  preface: The deeper you go, the harder what lives there.
  summary: Odile looks into an upper floor and the bottom one.
opening_scene:
  location: The Landing
  characters: [Odile]
  description: You stand on the lantern-lit landing between two stairs down.
  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 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 on either side.
  teaser: A lantern-lit landing.
  lore: Delvers gather here before they go down.
- name: The Upper Floor
  detail_level: stub
  kind: warren
  teaser: The upper level of the dungeon, one chamber deep.
  floor:
    rooms: 1
    danger: uneasy
    hazard: none
- name: The Bottom Floor
  detail_level: stub
  kind: warren
  teaser: The bottom level of the dungeon, one chamber deep.
  floor:
    rooms: 1
    danger: uneasy
    hazard: none
    monster_level: 4
connections:
- between: [The Landing, The Upper Floor]
  distance: adjacent
  travel_method: taking stairs
- between: [The Landing, The Bottom Floor]
  distance: adjacent
  travel_method: taking stairs
lib/engine_sweep/scripts/a-guard-waits-at-the-stairs.yml
# A POSTED GUARD STANDS ON THE WAY DOWN. Brask the Stairwarden is posted to The
# Upper Floor (`guards:`, written as `characters.posted_to_id`) and the file
# keeps him absent. When the floor is first laid out, the engine stands him in
# the room it rolled for the way down (room 2), as part of laying the floor
# out: once, for the world. He carries the stair key, and the stair down is
# locked with it, so the way down is: kill him, take the key, open the stair,
# go down.
#
# A second game walks onto the floor as it was laid out and finds him standing
# in the same room, alive in that game: the layout does not run again, and the
# dead stay dead only in the game that killed them.
story: A Guarded Stair
steps:
- id: into-the-upper-floor
  why: >-
    The floor is laid out on this turn. Its entry room is empty: the guard
    stands on the way down, never on the entry.
  type: "/move The Upper Floor"
  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: warren
    - 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)
    exits:
    - The Landing (realized)
    - The Upper Floor room 2 (stub)
    present: []
- id: the-guard-stands-on-the-way-down
  why: >-
    Room 2 holds the stair down to The Lower Floor, and Brask stands in it,
    hostile and whole. The room writer wrote nobody: he was put there by the
    layout, not by the writer.
  type: "/move The Upper Floor room 2"
  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)
    exits:
    - The Lower Floor (stub)
    - The Upper Floor room 1 (realized)
    - The Upper Floor room 3 (stub)
    present:
    - Brask the Stairwarden
    foes:
    - Brask the Stairwarden
    hp_of:
      Brask the Stairwarden: 6
- id: kill-the-guard
  why: >-
    One blow kills him, and the key he carried falls where he stood.
  type: "/attack Brask the Stairwarden"
  expect:
    changed: true
    present: []
    foes: []
- id: the-stair-key-off-the-floor
  type: "/take stair key"
  expect:
    carrying:
    - stair key
- id: the-stair-down-is-locked
  why: >-
    The stair kept its lock when the layout moved it onto room 2.
  type: "/move The Lower Floor"
  expect:
    location: The Upper Floor room 2 (realized)
    refused: true
    offers: locked
- id: his-key-opens-it
  type: "/unlock The Lower Floor with stair key"
  expect:
    changed: true
    refused: false
- id: down-to-the-lower-floor
  type: "/move The Lower Floor"
  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: warren
    - 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)
    exits_include:
    - The Upper Floor room 2 (realized)
    refused: false
- id: a-second-game-walks-onto-the-laid-out-floor
  player: second
  type: "/move The Upper Floor room 1"
  expect:
    location: The Upper Floor room 1 (realized)
    present: []
- id: and-finds-the-guard-where-the-layout-stood-him
  why: >-
    The same room, with no call: the floor and its guard are world rows from
    the first layout on. He is alive in this game.
  player: second
  type: "/move The Upper Floor room 2"
  expect:
    location: The Upper Floor room 2 (realized)
    present:
    - Brask the Stairwarden
    foes:
    - Brask the Stairwarden
    hp_of:
      Brask the Stairwarden: 6
lib/engine_sweep/scripts/a-deeper-floor-births-stronger-people.yml
# A FLOOR'S MONSTER LEVEL IS THE LEVEL OF EVERYBODY BORN ON IT. The Bottom
# Floor names `monster_level: 4`; The Upper Floor names none. Each is one room,
# and its room writer writes one person into it. The person born on the bottom
# floor is level 4 and the one born on the upper floor is level 1, the
# ordinary starting level: the hit die and the three abilities are rolled for
# both exactly as they are for anybody, and only the level is the floor's.
#
# A script may not assert a level directly, so it asserts the hit points a
# newly born person stands at, which are their maximum:
# hit die + (level - 1) * (hit die / 2 + 1). Hollin's die is a d6, so 18 is
# level 4 (and 6 would have been level 1); Wenna's is a d8, so 8 is level 1.
# The records file pins the `level` column itself.
story: A Deeper Floor
steps:
- id: down-to-the-bottom-floor
  why: >-
    The bottom floor is laid out as its one room, and the room writer writes
    Hollin into it. He is born at the floor's monster level, 4: on his d6 that
    is 18 hit points.
  type: "/move The Bottom Floor"
  browser:
    token: into-the-bottom-floor
    realizes: The Bottom Floor
    replies:
    - purpose: location
      content:
        description: The bottom level of the dungeon, one chamber cut far down.
        lore: Whatever lives this deep has lived here a long time.
        place_kind: warren
    - purpose: location
      content:
        description: A wide chamber where somebody has made a camp among the stones.
        lore: Delvers who stay down here too long stop going back up.
        items: []
        people:
        - fullname: Hollin Graves
          nickname: Hollin
          appearance: A delver's coat gone grey with stone dust.
          personality: Watchful and slow to speak.
          backstory: A delver who stopped going back up years ago.
          likes: The quiet down here.
          dislikes: Lanterns from above.
          fears: The surface.
    - purpose: arrival
      unavailable: true
  expect:
    location: The Bottom Floor room 1 (realized)
    present:
    - Hollin Graves
    hp_of:
      Hollin Graves: 18
- id: back-up-to-the-landing
  type: "/move The Landing"
  expect:
    location: The Landing (realized)
- id: into-the-upper-floor
  why: >-
    The upper floor names no monster level, so Wenna, written into its one
    room, is born at the ordinary level 1: on her d8 that is 8 hit points.
  type: "/move The Upper Floor"
  browser:
    token: into-the-upper-floor
    realizes: The Upper Floor
    replies:
    - purpose: location
      content:
        description: The upper level of the dungeon, one chamber just under the landing.
        lore: Delvers pass through here on their way to somewhere worse.
        place_kind: warren
    - purpose: location
      content:
        description: A low chamber with a bedroll by the wall.
        lore: Somebody sleeps here between delves.
        items: []
        people:
        - fullname: Wenna Cole
          nickname: Wenna
          appearance: A patched cloak and a sack of candles.
          personality: Cheerful and talkative.
          backstory: A candle-seller who follows the delvers down.
          likes: Company.
          dislikes: Silence.
          fears: Being left in the dark.
    - purpose: arrival
      unavailable: true
  expect:
    location: The Upper Floor room 1 (realized)
    present:
    - Wenna Cole
    hp_of:
      Wenna Cole: 8

…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.
@calebl
calebl merged commit 98cec5f into main Oct 30, 2026
4 checks passed
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
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