Repository navigation
Raise the game's level once on each floor that says so, and heal to full - #97
Merged
Merged
Conversation
The first time a game stands on a floor (a place with a floors row, or a
room of one), the engine writes that game's row of playthrough_floors:
when it arrived, and on a floor whose row says levels_up, the level it
raised the player to, one past the level the game had (capped at 20).
The player is mended to the new maximum on the same turn, before the way
in or the room takes a toll and before anybody strikes. A floor is
reached once a game; walking back onto it writes and heals nothing. The
world's characters.level is never written.
The game's level is the highest such row's level; every max-HP reader
asks the game (Game::level_at, Game::max_hp_at): vitals_for, a toll's
and a blow's condition at its own story time, a new vitals row, the mend
cap, and the glance's sheet line. The turn's scene claims the row, as it
claims a toll, and the engine-alone report notes it in the engine's own
words: "Reaching {floor} raised {fullname} to level {level}: {max} hit
points, and healed to full." No request carries it.
Migration 20261031090000 adds floors.levels_up and playthrough_floors,
applied to every world dump as Rails' SQLite adapter applies it.
store::OPTIONAL_COLUMNS and OPTIONAL_SHAPES hold its facts, 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 three-floors-that-raise-you (the game's loader at server
b2a4523, then the migration and levels_up set) and script
a-floor-raises-a-level-once, in PASSING with a golden and records.
…dstep/server 132b475 The game's pin PR (renderedstep/server#373) reads levels_up:, so the dump is now the one its own loader writes from a schema load: levels_up is written by the loader rather than by hand. The rows are the same; only timestamps, the order playthrough_floors and its indexes are dumped in, and the walk's id pin on its sequence moved. No golden or records file moves.
…deredstep/server 132b475)
calebl
added a commit
to renderedstep/server
that referenced
this pull request
Oct 30, 2026
9bbff37 adds to the head pinned before only the engine's CI and parity/worlds/three-floors-that-raise-you.sql rebuilt from this branch's loader (132b475). No golden, script or vector portion changed: bin/rails engine:vendored passes, engine:rust_gates passes all 110 scripts.
calebl
added a commit
to renderedstep/server
that referenced
this pull request
Oct 30, 2026
Pin renderedstep/engine#97's head (e8a28d2): raise the game's level once on each floor that says so, and heal to full
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 5 of the dungeon plan, engine half: levelling stage 1, one level per floor reached, healed to full (D8 b, X1 a, X2, X3, X5).
levels_upraises the game's level by one the first time that game stands on it, and heals the player to full on the same turn. This applies to this game only: the world'scharacters.levelis never written.playthrough_floors, gets one row per game and floor, written once. Stage 6's countdown will read itsarrived_at.A world without
levels_upplays byte for byte as before. No model was called.The notice
Exact wording (
Game::floor_to_s, the plan's §4.3 draft as written):For example:
Reaching The First Floor raised Odile to level 4: 23 hit points, and healed to full.Where it shows:
the game: Reaching …, beside the tolls'the world: …notes.playthrough_floors.scene_id), the way it claims a toll. The server's log entry shows it from there (below).tests/floor_levels.rschecks every request of a levelling move, and the new script's records file holds the requests themselves.What changed
turn.rsreach_floor, called first inon_arrival, on both the engine-alone and the told path. When the room stood in is a floor, or a room of one (playthrough::floor_of, now shared with stage 3's monster level), and this game has no row for that floor yet:playthrough_id,location_id,arrived_at(story now) andlevel;levelis the game's level plus one whenfloors.levels_upis true and the player has a stat block, capped atCharacter::LEVELS.max(20); otherwise it is null;mend, so the dead are never raised).It writes nothing when the game is over, or when the database has no
playthrough_floorstable. It runs before the doorway's toll and the room's own hazard, and before the riposte. So every toll and blow of that turn is paid, and described, at the new maximum. A hazard on the way in still costs hit points after the heal (the_way_onto_a_floor_takes_its_toll_after_the_floor_has_healed_the_player).playthrough.rs.Game::level_at(character, at)andGame::max_hp_at(character, at), withlevel/max_hpfor now. For the player, the level is the highest non-nullplaythrough_floors.levelamong this game's rows witharrived_at <= at. Anybody else, and a player no floor has raised, has the world's level.hit_points(level, die)is the one formula.max_hp(row)keeps the world's figure for the fight simulator.Game::floors_reachedandGame::floor_to_s(the notice).Every max-HP reader the plan lists now asks the game, re-checked at this main:
vitals_for(playthrough.rs), so the glance's condition, the moment's "is hurt (H of M)", arrival, revisit andoutcome::Statefollow;toll_to_s, at the toll'sstory_timestamp;turn.rs: the thrown-thing blow, a new vitals row at full, themendcap, andblow_to_s, which now takes the maximum from its caller:strike(now), andclose_fightand the narrated facts card (narrates.rs) at the blow'sstory_timestamp;sheetline (level {level}, d{die}), which reads the game's level.The fight simulator's
arena.rskeeps the world'smax_hp, because a simulated fight is never on a floor.told.rsclaim_floors, a journal step (told_floors) aftertold_tolls. The turn's scene claims this game's unclaimed floor rows. No paragraph tells them.Notes.
Mechanicsremembers the last floor row at the start of the line (floors_before, as it doestolls_before).answered_by_the_worldaddsthe game: {notice}for each new row that raised a level.Store.
store::PLAYTHROUGH_FLOORSjoinsOPTIONAL_LOADED, which now also covers the one optional table the engine writes. Its whole shape is inOPTIONAL_SHAPES.floors.levels_upis inOPTIONAL_COLUMNS.Store::has(table)reports whether the table is there.SCHEMA_VERSIONandshape.txtdo not move, so the game at its current pin still opens and plays. Therelevels_upreads as false and nothing is written.Migration 20261031090000 (
AddLevelsUpAndCreatePlaythroughFloors), applied to all 33 world dumps inparity/worlds/:bin/rails db:migrateran on a copy of each world, in a scratch checkout of the game atSERVER_COMMITholding only this migration.refused_lines, optional to the engine). Its version row was added before the run and deleted after, as in stage 3.two-floors-and-a-keyalready had that migration, so its dump is Rails' own.floorsgains"levels_up" boolean DEFAULT FALSE NOT NULL(each row a trailing0); there is a newplaythrough_floorstable with its four indexes; and the version row is added.README. The
playthroughrow of the module table.What did not move
parity --check parity: AGREED 106 of 110. The other four are runner-only, as before.a-floor-laid-out-to-its-count,a-guard-waits-at-the-stairs,a-keyed-stair-onto-a-place-stays-keyed,a-deeper-floor-births-stronger-people) now write aplaythrough_floorsrow with a null level on each first arrival.playthrough_floorsis not inparity::WRITTEN_TABLES, so no records file moved, and no floor in those worlds raises a level.fight --check sim: AGREED, 10 fight goldens, unchanged. Nothing was re-blessed. The reference fights are fought in the simulator's own worlds, which have no floors, so no game level ever differs from the world's.Sweep
New world
three-floors-that-raise-youand scripta-floor-raises-a-level-once(11 steps), inPASSINGwith a golden and a records file.How the world was made. It is the game's loader at
SERVER_COMMIT(b2a4523) loading the world file below withoutlevels_up:, which that loader refuses. Then the migration ran, andUPDATE floors SET levels_up = 1wrote what the new loader will write. As with stages 2 and 3, rebuild the dump from the pin branch's own loader once it readslevels_up:.The world. Odile is level 3 on a d8 (18 hit points). Under the landing are three floors of two rooms each, every one with
levels_up: true. The walk goes floor 1 → 2 → 1 → 2:harm 5on the landing:Odile unhurt -> hurt (13 of 18).harm 3:unhurt -> hurt (20 of 23), so the maximum is the game's.harm 4:unhurt -> hurt (24 of 28).harm 1:hurt (24 of 28) -> hurt (23 of 28). She keeps level 5 on floor 1.mend 99:hurt (23 of 28) -> unhurt. It stops at level 5's 28, not a level 6.the game: Reaching The First Floor raised Odile to level 4: 23 hit points, and healed to full., and its hp is 23. The level is the game's, raised from the world's 3.The sweep has no expectation key for a level or a maximum. So the script asserts them through
hp(the heal is to the new maximum) and throughharm/mend'schange, which reads (H of MAX). The notice shows only on an engine-alone move: a browser step's note is its token. I checked everyexpect:against the engine's dumps: 24 expectations, 0 mismatches.Tests
tests/floor_levels.rs(new; live path with a scripted model, plus the engine alone):level 4, d8and thenlevel 5, d8, and the world'scharacters.levelstays 3. A second room of floor 1, and walking back and down again, write no row and heal nothing.harmandmendsay (H of 23) and (H of 28).levels_upfalse writes a row with a null level and heals nobody.undertowon the way in is paid after the heal: hp and the toll'shp_afterare 23 less the damage.Gameover records. A toll before the arrival is said at (15 of 18) and one at it at (20 of 23).max_hp_atgives 18 then 23. A non-player keeps the world's level.floor_to_sgives the exact notice.tests/engine.rs).floors.levels_updeclared nullable is refused (found column floors.levels_up boolean pk=0).playthrough_floorsmissing its unique index is not whole, so it is refused.every_world_has_the_shape_the_engine_is_written_againstholds for all 34 dumps.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: 106 of 110 agree (the four runner-only, as before).fight --check sim: AGREED, 10 fight goldens.SERVER_COMMIT(b2a4523), on its own schema (no new table or column), with the extension built from this checkout:parity/runner.sh: 109 of 110 agree; the new script is "not in the checkout yet";engine:rust_gates: PASSED for 109 scripts (goldens, invariants, doctor and audit).SERVER_COMMITdoes not move. At its pin (b2a4523) the game has neither the table nor the column, and the engine opens it and plays as before.What the server pin PR must change
Line numbers are at
b2a4523.db/migrate/20261031090000_add_levels_up_and_create_playthrough_floors.rb. Use exactly this version and declaration, so the dumps here stay byte-true.store::OPTIONAL_COLUMNSandstore::OPTIONAL_SHAPEShold every fact of it.db/schema.rbmoves to2026_10_31_090000. No later migration was on main (86b9764) when this was written.fell_at(plan §1.2) is left to stage 6, which adds its own migration forminutesanyway.Playthrough::Floor(tableplaythrough_floors; any name).belongs_to :playthrough,:locationand:scene, optional: true.levelinCharacter::LEVELS, allowing nil, andlocationunique per playthrough.#to_sis the notice, word for word asGame::floor_to_s:"Reaching #{location.name} raised #{playthrough.character.fullname} to level #{level}: #{max} hit points, and healed to full.", wheremaxishit_die + (level - 1) * (hit_die / 2 + 1)on the player's hit die. It is nil for a row with no level.Playthrough has_many …, dependent: :destroy;Scene has_many …, dependent: :nullify(as:tolls,scene.rb:193);Location has_many …, dependent: :destroy(as:tolls,location.rb:65). The foreign keys areNO ACTION.Floorvalidateslevels_upas a boolean.Game::level_at/Game::max_hp_atexactly:Playthrough#level_for(character, at: nil)and#max_hp_for(character, at: nil).levelof itsplaythrough_floorsrows witharrived_at <= at(every row whenatis nil). With no such row, and for anybody else, it ischaracter.level.Character#max_hpon a game asks it:Playthrough::Vitals:.instantiate!(vitals.rb:65),Condition.for(:80),#max(:129), and so thehp_within_the_stat_blockvalidation (:139-144). Without the validation change, any Ruby save of a levelled player's row is refused.Playthrough::Turn#mend!(turn.rb:1334), the cap.Blow#condition(blow.rb:63), at the blow'sstory_timestamp.Toll#condition(toll.rb:134), at the toll'sstory_timestamp.Playthrough::Mechanics::State#sheet(mechanics.rb:127-134):"level #{playthrough.level_for(character)}, d#{character.hit_die}". That is whatengine:rust_gatesholds to the engine's glance.Character#advance!(character.rb:840) stays uncalled. Its comment should say why: it writes the world, and a level is the game's.Story::Doctor#hp_above_maximum(doctor.rb:3390) andStory::Repair#repair_hp_above_maximum(repair.rb:465) in this same PR. Both readrow.playthrough.max_hp_for(row.character)instead ofrow.character.max_hp.:safe, a routinerake game:repairclamps them back down to the world's maximum.lib/world_seed/loader.rb). Thefloor:keys gainlevels_up.trueorfalse.levels_up: "yes".levels_up: trueunderfloor:when it is set. Round-trip test.Protocol::V1#entry(protocol/v1.rb:71-77) appends this game's floor rows the scene claimed that raised a level, by id, after its tolls (filtered to the playthrough as the tolls are):… + floors.filter_map(&:to_s). Update thenoticesdescription indocs/protocol/v1/openapi.jsonif it names what a notice is. Test it with a scene that claimed a levelling row.playthrough_floorstoPlaythrough::SqlitePackage's packaged tables (PLAYTHROUGH_TABLES,sqlite_package.rb:27-43).Story::ForkandStory::Snapshotcopy no playthroughs, so they need nothing.playthrough_floorsrow whose location has nofloorsrow. A re-seed that removesfloor:leaves exactly this, and the game keeps its level.lib/engine_sweep/worlds/three-floors-that-raise-you.ymlandlib/engine_sweep/scripts/a-floor-raises-a-level-once.yml, with their comments andwhy:notes (below).test/engine_parity/a-floor-raises-a-level-once.checks.json.test/ruby_loop_coverage.ymlif it must list the script.parity/worlds/three-floors-that-raise-you.sqlhere from that branch's loader.Out of scope, as the brief says: the countdown, healing items, safe rooms, rest, the front end, wardens, and any prompt change.
lib/engine_sweep/worlds/three-floors-that-raise-you.yml
lib/engine_sweep/scripts/a-floor-raises-a-level-once.yml